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..f20c5877fd720236e5fd21f17b133d0b8f128d51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,370 @@ +## 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-build/ +_codex_obj/ +.codex-build/ +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..8edf59bf40a7383c2513722b7baf5336b68fbd26 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# 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/ +COPY Tools/TranslationBackfill/TranslationBackfill.csproj Tools/TranslationBackfill/ +COPY Tests/TaskTrackingSystem.Tests/TaskTrackingSystem.Tests.csproj Tests/TaskTrackingSystem.Tests/ + +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..c7c00fc130cbb9d99b5aef48a328b34c8b7c8252 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Comment.cs @@ -0,0 +1,31 @@ +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 string? MessageMy { 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 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..0876ad1eb80d7cc69148efd87eeed1b56e2606fc --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Issue.cs @@ -0,0 +1,60 @@ +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? TitleMy { get; set; } + + public string? Description { get; set; } + + public string? DescriptionMy { get; set; } + + public long? AssignedTo { get; set; } + + public decimal? EstimatedHours { get; set; } + + public decimal? ActualHours { get; set; } + + public string? DelayReason { get; set; } + + public string? DelayReasonMy { get; set; } + + public bool IsBlocked { get; set; } + + public string? BlockedBy { get; set; } + + public string? BlockedByMy { 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..c0d8289fd8fb7cf54c569ec4d524153dc3366270 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Menu.cs @@ -0,0 +1,43 @@ +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? MenuNameMy { get; set; } + + 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..765c228229fa4828db3b5818114576b2bc2d5145 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Notification.cs @@ -0,0 +1,36 @@ +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? TitleMy { get; set; } + + public string Body { get; set; } = null!; + + public string? BodyMy { get; set; } + + 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..d594acb4bc17bd0264f574a81851bf8f4ac257b6 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Permission.cs @@ -0,0 +1,39 @@ +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? ActionNameMy { get; set; } + + 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..4802ee45af93d38a89cdab88c3ec84b0332a8c2f --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Project.cs @@ -0,0 +1,43 @@ +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? NameMy { get; set; } + + public string? Description { get; set; } + + public string? DescriptionMy { 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..871cc93c71860ea72c6d978d6053e288d3f33b81 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Role.cs @@ -0,0 +1,33 @@ +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? NameMy { get; set; } + + public string? Description { get; set; } + + public string? DescriptionMy { 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..4f42b36c435019924b15ada3a90defe274d400c8 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Task.cs @@ -0,0 +1,51 @@ +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? TitleMy { get; set; } + + public string? Description { get; set; } + + public string? DescriptionMy { 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..b1b895d7e2f426d3eb34667fb300871b151dcd21 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/User.cs @@ -0,0 +1,54 @@ +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? FirstNameMy { get; set; } + + public string? LastNameMy { get; set; } + + 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..d29071dc8c5c97045758ba09f67fe5914fb8beb1 --- /dev/null +++ b/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj @@ -0,0 +1,33 @@ + + + + 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..5c05cfade4a8636399dde41d25f259585d771859 --- /dev/null +++ b/TaskTrackingSystem.Shared/DisplayFormats.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using TaskTrackingSystem.Shared.Localization; + +namespace TaskTrackingSystem.Shared; + +public static class DisplayFormats +{ + public const string DateFormat = "dd-MM-yyyy"; + + public static string Date(DateTime value) => + AppLocalization.LocalizeDigits(value.ToString(DateFormat, CultureInfo.InvariantCulture)); + + public static string Date(DateTime? value) => + value.HasValue ? Date(value.Value) : string.Empty; + + public static string Date(DateOnly value) => + AppLocalization.LocalizeDigits(value.ToString(DateFormat, CultureInfo.InvariantCulture)); + + public static string Number(int value) => + AppLocalization.LocalizeDigits(value.ToString(CultureInfo.InvariantCulture)); + + public static string Number(long value) => + AppLocalization.LocalizeDigits(value.ToString(CultureInfo.InvariantCulture)); + + public static string Number(decimal value, string format = "0.#") => + AppLocalization.LocalizeDigits(value.ToString(format, CultureInfo.InvariantCulture)); + + public static string Number(double value, string format = "0.#") => + AppLocalization.LocalizeDigits(value.ToString(format, 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..e0dd4b88e6f77d6c3dace9cc68ef61a471226032 --- /dev/null +++ b/TaskTrackingSystem.Shared/Localization/AppLocalization.cs @@ -0,0 +1,2215 @@ +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.ok"] = "OK", + ["common.score"] = "score", + ["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.refresh"] = "Refresh", + ["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.systemAdmin"] = "System Admin", + ["common.role"] = "Role", + ["common.module"] = "Module", + ["common.name"] = "Name", + ["common.firstName"] = "First name", + ["common.lastName"] = "Last name", + ["common.username"] = "Username", + ["common.phone"] = "Phone", + ["common.phoneNumber"] = "Phone number", + ["common.keepHere"] = "Keep here", + ["common.moveToEnglish"] = "Move to English", + ["common.generating"] = "Generating...", + ["common.translationNotConfigured"] = "Translation is not configured.", + ["common.complete"] = "Complete", + ["common.aiGenerated"] = "AI generated", + ["common.needsReview"] = "Needs review", + ["common.missing"] = "Missing", + ["common.translationReview"] = "AI-generated content is ready for review. Check it before saving.", + ["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.hoursAbbrev"] = "hrs", + ["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.error"] = "Error", + ["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.searchUsers"] = "Search Users", + ["common.searchByName"] = "Search by name...", + ["common.newUser"] = "New User", + ["common.taskCode"] = "Task Code", + ["common.selectedMember"] = "Selected member", + ["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.", + ["pagination.showing"] = "Showing", + ["pagination.of"] = "of", + ["pagination.results"] = "results", + ["pagination.rowsPerPage"] = "Rows per page", + ["pagination.firstPage"] = "First page", + ["pagination.previousPage"] = "Previous page", + ["pagination.nextPage"] = "Next page", + ["pagination.lastPage"] = "Last page", + ["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", + ["task.selectTaskPrompt"] = "Select a task from the left panel to assign it to a team member.", + ["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", + ["error.title"] = "Error", + ["error.heading"] = "Error", + ["error.processingRequest"] = "An error occurred while processing your request.", + ["error.requestId"] = "Request ID", + ["error.developmentMode"] = "Development Mode", + ["error.developmentDetails"] = "Switching to the Development environment will display more detailed information about the error that occurred.", + ["error.developmentWarning"] = "The Development environment should not be enabled for deployed applications.", + ["error.developmentSensitive"] = "It can expose sensitive exception details to end users.", + ["error.developmentInstructions"] = "For local debugging, set the ASPNETCORE_ENVIRONMENT environment variable to Development and restart the application.", + ["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.assignmentSaved"] = "Assignment saved successfully!", + ["task.assigneeUpdated"] = "Task assignee updated successfully!", + ["task.failedUpdateAssignee"] = "Failed to update assignee.", + ["task.failedLoadProjectMembers"] = "Failed to load project members.", + ["task.assigneeMustBelong"] = "Task assignee must belong to the selected project.", + ["task.titleRequiredEitherLanguage"] = "At least one task title is required.", + ["task.saveAssignmentTitle"] = "Save Assignment?", + ["task.saveAssignmentConfirm"] = "Are you sure you want to save this task assignment?", + ["project.saveAssignmentTitle"] = "Save Assignment?", + ["project.saveAssignmentConfirm"] = "Are you sure you want to save these project member assignments?", + ["issue.yesSave"] = "Yes, Save", + ["common.saveChanges"] = "Save Changes", + ["common.addIssue"] = "Add Issue", + ["common.addIssueTitle"] = "Add Issue?", + ["issue.saveAddConfirmMessage"] = "Are you sure you want to save this issue?", + ["issue.yesAdd"] = "Yes, Add", + ["common.editIssue"] = "Edit Issue", + ["common.noTaskSelectedForIssue"] = "No task selected", + ["common.unassignedMembers"] = "Unassigned", + ["common.removeOwnerFromTask"] = "Remove owner from this task", + + ["page.dashboard"] = "Dashboard", + ["page.managerDashboard"] = "Manager Dashboard", + ["page.employeeDashboard"] = "Employee Dashboard", + ["page.projects"] = "Projects", + ["page.reports"] = "Reports", + ["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.", + + ["dashboard.overview"] = "Overview", + ["dashboard.welcomeBack"] = "Welcome back, {name}", + ["dashboard.adminIntro"] = "Projects, users, and team progress at a glance.", + ["dashboard.totalUsers"] = "Total users", + ["dashboard.totalProjects"] = "Total projects", + ["dashboard.activeTasks"] = "Active tasks", + ["dashboard.completionRate"] = "Completion rate", + ["dashboard.projectActivity"] = "Project Activity", + ["dashboard.projectActivityDesc"] = "Active vs completed tasks per month.", + ["dashboard.taskDistribution"] = "Task Distribution", + ["dashboard.totalTasks"] = "{count} total tasks", + ["dashboard.completedCount"] = "Completed: {count}", + ["dashboard.activeCount"] = "Active: {count}", + ["dashboard.allProjects"] = "All Projects", + ["dashboard.projectsSnapshot"] = "A quick snapshot of the current project list.", + ["dashboard.viewAll"] = "View all", + ["dashboard.tasksUnit"] = "tasks", + ["dashboard.calendar"] = "Calendar", + ["dashboard.calendarDesc"] = "Today and upcoming project starts.", + ["dashboard.events"] = "Events", + ["dashboard.projectTimeline"] = "Project Timeline", + ["dashboard.projectTimelineDesc"] = "Browse every project in a gantt view and adjust the month window below.", + ["dashboard.from"] = "From", + ["dashboard.to"] = "To", + ["dashboard.noProjectsToDisplay"] = "No projects to display.", + ["dashboard.thisMonth"] = "this month", + ["dashboard.vsLastMonth"] = "vs last mo", + ["dashboard.upcoming"] = "Upcoming", + ["dashboard.active"] = "Active", + ["dashboard.queued"] = "queued", + ["dashboard.soon"] = "soon", + ["dashboard.late"] = "late", + ["dashboard.employeeTitle"] = "Employee Dashboard", + ["dashboard.teamMember"] = "Team Member", + ["dashboard.myWork"] = "My Work", + ["dashboard.employeeIntro"] = "Tasks, deadlines, and your progress in one place.", + ["dashboard.viewTasks"] = "View Tasks", + ["dashboard.loadingDashboard"] = "Loading dashboard...", + ["dashboard.tasksInProgressDesc"] = "Tasks currently being worked on", + ["dashboard.noTasksInProgress"] = "No tasks in progress right now.", + ["dashboard.todoUpcoming"] = "To Do & Upcoming", + ["dashboard.todoUpcomingDesc"] = "Tasks waiting for action or coming up soon", + ["dashboard.noUpcomingTasks"] = "No upcoming tasks found.", + ["dashboard.overdueDesc"] = "Tasks past their due date", + ["dashboard.noOverdueTasks"] = "No overdue tasks. Nice work.", + ["dashboard.relatedTasks"] = "Related Tasks", + ["dashboard.tasksMarkedOn"] = "Tasks marked on {date}", + ["dashboard.noTasksForDay"] = "No tasks are scheduled for this day.", + ["dashboard.dueSoon"] = "Due Soon", + ["dashboard.managerTitle"] = "Manager Dashboard", + ["dashboard.teamView"] = "Team View", + ["dashboard.myProjects"] = "My Projects", + ["dashboard.managerIntro"] = "Track your team, project health, and weekly task progress in one place.", + ["dashboard.pendingTasksNotice"] = "{count} tasks are still pending.", + ["dashboard.taskReport"] = "Task Report", + ["dashboard.weeklyTaskProgress"] = "Weekly Task Progress", + ["dashboard.weeklyTaskProgressDesc"] = "Completed vs pending tasks per week", + ["dashboard.teamProgress"] = "Team Progress", + ["dashboard.teamProgressDesc"] = "Members with active workload", + ["dashboard.noAssignedWork"] = "No assigned work yet.", + ["dashboard.upcomingDeadlines"] = "Upcoming Deadlines", + ["dashboard.projectsEndingSoon"] = "Projects ending soon", + ["dashboard.ends"] = "ends", + ["dashboard.noUpcomingDeadlines"] = "No upcoming deadlines.", + ["dashboard.clickViewTasks"] = "Click \"View Tasks\" to drill in", + ["dashboard.complete"] = "Complete", + ["dashboard.noProjectsForAccount"] = "No projects available for this account.", + ["dashboard.activeProjects"] = "Active Projects", + ["dashboard.totalTasksMetric"] = "Total Tasks", + ["dashboard.teamMembers"] = "Team Members", + ["dashboard.overdueTasks"] = "Overdue Tasks", + ["dashboard.allActive"] = "all active", + ["dashboard.acrossProjects"] = "across projects", + ["dashboard.yourTeam"] = "your team", + ["dashboard.needsAttention"] = "needs attention", + ["dashboard.atRisk"] = "At Risk", + ["dashboard.pending"] = "Pending", + ["dashboard.today"] = "Today", + ["dashboard.done"] = "Done", + ["dashboard.daysAgo"] = "{count}d ago", + ["dashboard.daysLeft"] = "{count}d left", + ["dashboard.dueToday"] = "Due today", + + + ["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", + ["status.active"] = "Active", + ["status.inactive"] = "Inactive", + ["field.name"] = "Name", + ["field.title"] = "Title", + ["field.email"] = "Email", + ["field.phone"] = "Phone", + ["field.created"] = "Created", + ["field.taskCode"] = "Task Code", + ["field.ipAddress"] = "IP Address", + ["project.manageAll"] = "Manage all projects.", + ["project.employeesInProject"] = "Employees in this project", + ["project.yourAssigned"] = "Your assigned projects.", + ["project.newProject"] = "New Project", + ["project.editProject"] = "Edit Project", + ["project.createProject"] = "Create Project", + ["project.untitledProject"] = "Untitled project", + ["project.noProjectsYet"] = "No projects yet", + ["project.createFirstProject"] = "Create your first project to get started.", + ["project.noAssignedProjects"] = "You have not been assigned to any projects.", + ["project.projectName"] = "Project name", + ["project.projectDescriptionOptional"] = "Project description (optional)", + ["project.updateTitle"] = "Update Project?", + ["project.updateConfirm"] = "Are you sure you want to update this project?", + ["project.createTitle"] = "Create Project?", + ["project.createConfirm"] = "Are you sure you want to create this project?", + ["project.deleteTitle"] = "Delete Project?", + ["project.deleteConfirm"] = "Are you sure you want to delete this project? This action cannot be undone.", + ["project.created"] = "Project created successfully!", + ["project.updated"] = "Project updated successfully!", + ["project.deleted"] = "Project deleted successfully!", + ["project.failedDelete"] = "Failed to delete project.", + ["task.manageAll"] = "View and manage all tasks across projects.", + ["task.filterByProject"] = "Filter by Project", + ["task.yourAssigned"] = "Your assigned tasks.", + ["task.newTask"] = "New Task", + ["task.editTask"] = "Edit Task", + ["task.createTask"] = "Create Task", + ["task.untitledTask"] = "Untitled task", + ["task.noTasksYet"] = "No tasks yet", + ["task.createFirstTask"] = "Create your first task to get started.", + ["task.taskTitle"] = "Task title", + ["task.taskDescription"] = "Task description", + ["task.selectProject"] = "Select project...", + ["task.loadingBoard"] = "Loading board...", + ["task.dragHint"] = "Drag tasks between columns to update their status.", + ["task.viewingArchived"] = "Viewing archived tasks.", + ["task.backToBoard"] = "Back to Board", + ["task.archivedTasks"] = "Archived Tasks", + ["task.archived"] = "Archived", + ["task.dropTasksHere"] = "Drop tasks here", + ["task.noTasks"] = "No tasks", + ["task.of"] = "of", + ["task.due"] = "Due", + ["task.previousPage"] = "Previous page", + ["task.nextPage"] = "Next page", + ["task.createTitle"] = "Create Task?", + ["task.createConfirm"] = "Are you sure you want to create this task?", + ["task.updateTitle"] = "Update Task?", + ["task.updateConfirm"] = "Are you sure you want to update this task?", + ["task.deleteTitle"] = "Delete Task?", + ["task.deleteConfirm"] = "Are you sure you want to delete this task? This action cannot be undone.", + ["task.archiveDoneTitle"] = "Archive Done Tasks?", + ["task.archiveDoneInProject"] = "Archive {count} Done task(s) in this project? Archived tasks will be hidden from active views.", + ["task.archiveDoneAll"] = "Archive {count} Done task(s) across all visible projects? Archived tasks will be hidden from active views.", + ["task.noDoneToArchive"] = "No Done tasks to archive.", + ["task.created"] = "Task created successfully!", + ["task.updated"] = "Task updated successfully!", + ["task.deleted"] = "Task deleted successfully!", + ["task.failedCreate"] = "Failed to create task.", + ["task.failedDelete"] = "Failed to delete task.", + ["task.failedMove"] = "Failed to move task. Please try again.", + ["task.failedArchiveDone"] = "Failed to archive Done tasks.", + ["task.archivedOneDone"] = "Archived 1 Done task.", + ["task.archivedManyDone"] = "Archived {count} Done tasks.", + ["task.archive"] = "Archive", + ["role.rolesAccess"] = "Roles & Access", + ["role.rolesAccessDesc"] = "Manage roles, assign access and permissions, and control visibility.", + ["role.newRole"] = "New Role", + ["role.editRole"] = "Edit Role", + ["role.createNewRole"] = "Create New Role", + ["role.roleName"] = "Role Name", + ["role.roleNameBurmese"] = "Burmese Name", + ["role.roleNameEnglish"] = "English Name", + ["role.descriptionBurmese"] = "Burmese Description", + ["role.descriptionEnglish"] = "English Description", + ["role.searchByName"] = "Search roles by name...", + ["role.loadingRoles"] = "Loading roles...", + ["role.noRolesYet"] = "No roles yet", + ["role.createFirstRole"] = "Create your first role to get started.", + ["role.manageAccess"] = "Manage Access", + ["role.access"] = "Access", + ["role.assignAccess"] = "Assign Access", + ["role.accessHint"] = "Pages control where the role can go. Actions control what it can do.", + ["role.createHint"] = "Choose what this role can see and do on the right. It will be saved with the role.", + ["role.descriptionPlaceholder"] = "What can this role do?", + ["role.examplePlaceholder"] = "e.g. Manager, Viewer", + ["role.updateTitle"] = "Update Role?", + ["role.updateConfirm"] = "Are you sure you want to update this role?", + ["role.createTitle"] = "Create Role?", + ["role.createConfirm"] = "Are you sure you want to create this role?", + ["role.deleteTitle"] = "Delete Role?", + ["role.deleteConfirm"] = "Are you sure you want to delete this role? This action cannot be undone.", + ["role.cannotDeleteTitle"] = "Cannot Delete Role", + ["role.cannotDeleteUsers"] = "You can't delete this role because there are {count} users in this role.", + ["role.created"] = "Role created successfully!", + ["role.updated"] = "Role updated successfully!", + ["role.deleted"] = "Role deleted successfully!", + ["role.failedDelete"] = "Failed to delete role.", + ["role.saveAccess"] = "Save Access", + ["role.accessConfigured"] = "Configure access and permissions", + ["role.permissionsHint"] = "Menus open pages. Permissions unlock actions.", + ["role.changeApplyAfterSave"] = "Changes apply immediately after saving.", + ["role.noAccessItemsFound"] = "No access items found in database.", + ["role.noPagesMatchSearch"] = "No pages or permissions match your search.", + ["role.pagePermissionHint"] = "Page access lets the role open this section. Permissions let it use the buttons and actions inside.", + ["role.nothingInsidePage"] = "Nothing else is available inside this page.", + ["role.crudPermissions"] = "CRUD permissions for this page", + ["role.willBeSaved"] = "will be saved", + ["role.selectedItems"] = "{count} selected item(s)", + ["role.accessSaved"] = "Access saved successfully.", + ["role.failedLoadAccess"] = "Failed to load access items. Please try again.", + ["role.failedSaveAccess"] = "Failed to save access.", + ["common.unknown"] = "Unknown", + ["common.saving"] = "Saving...", + ["common.creating"] = "Creating...", + ["common.searchRoles"] = "Search Roles", + ["common.searchTasksByTitle"] = "Search tasks by title...", + ["common.allStatuses"] = "All Statuses", + ["common.allPriorities"] = "All Priorities", + ["common.budgetedHours"] = "Budgeted Hours", + ["common.budgetedHoursOptional"] = "Budgeted hours (optional)", + + ["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.healthWatch"] = "Watch", + ["report.health"] = "Health", + ["report.delay"] = "Delay", + ["report.noEffortEntries"] = "No effort entries", + ["report.noEffortEntriesDesc"] = "No timesheet effort entries matched your filters.", + ["report.totalEstimatedEffort"] = "Total Estimated Effort", + ["report.variance"] = "Variance", + ["report.utilization"] = "Utilization", + ["report.timesheetEntries"] = "timesheet issue entry(s)", + ["report.delayType"] = "Delay Type", + ["report.days"] = "days", + ["report.dueIn"] = "Due in", + ["report.noReasonRecorded"] = "No reason recorded", + ["report.noOverdueTasksDesc"] = "Great! No tasks are overdue or at risk based on the current filters.", + ["report.aheadOfSchedule"] = "Ahead of Schedule", + ["report.blockedOverdue"] = "Blocked / Overdue", + ["report.noProjectData"] = "No project data found matching criteria.", + ["report.employee"] = "Employee", + ["report.assignedIssues"] = "Assigned Issues", + ["report.openOverdue"] = "Open / Overdue", + ["report.workload"] = "Workload", + ["report.trend"] = "Trend", + ["report.workloadHigh"] = "High", + ["report.workloadLight"] = "Light", + ["report.workloadBalanced"] = "Balanced", + ["report.trendImproving"] = "Improving", + ["report.trendDeclining"] = "Declining", + ["report.trendStable"] = "Stable", + ["report.noEmployeeData"] = "No employee data found matching criteria.", + ["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.endDateBeforeStart"] = "End date cannot be before start date.", + ["validation.taskTitleRequired"] = "Task title is required.", + ["validation.selectProjectRequired"] = "Please select a project.", + ["validation.selectTaskRequired"] = "Please select a task.", + ["validation.issueTitleRequired"] = "Issue title is required.", + ["validation.completeNameRequired"] = "Enter a complete name in Burmese or English.", + ["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.", + ["validation.fillAllFields"] = "Please fill in all required fields.", + + ["common.password"] = "Password", + ["common.usernameOrEmail"] = "Username or Email", + ["common.email"] = "Email", + ["auth.registerAccountTitle"] = "Create your Taskify account", + ["auth.alreadyHaveAccess"] = "Already have access?", + ["auth.firstName"] = "First Name", + ["auth.lastName"] = "Last Name", + ["auth.firstNamePlaceholder"] = "First name", + ["auth.lastNamePlaceholder"] = "Last name", + ["auth.phone"] = "Phone (Optional)", + ["auth.phonePlaceholder"] = "Phone number", + ["auth.register"] = "Register", + ["auth.registerTitle"] = "Register - Taskify", + ["auth.usernameOrEmail"] = "Username or Email", + ["auth.enterUsernameOrEmail"] = "Enter username or email", + ["auth.recoveryCode"] = "Recovery Code", + ["auth.enterRecoveryCode"] = "Enter recovery code", + ["auth.newPassword"] = "New Password", + ["auth.resetPasswordHelp"] = "Use the recovery code configured in the API to set a new password.", + ["auth.unableToResetPassword"] = "Unable to reset password.", + ["task.loadingDetails"] = "Loading task details...", + ["task.notFound"] = "Task Not Found", + ["task.notFoundDesc"] = "The task you are looking for does not exist or has been deleted.", + ["task.editTitle"] = "Edit title", + ["task.saving"] = "Saving...", + ["task.properties"] = "Properties", + ["comment.comments"] = "Comments", + ["comment.deleteComment"] = "Delete comment", + ["comment.deleteCommentTitle"] = "Delete Comment?", + ["comment.deleteCommentConfirm"] = "Are you sure you want to delete this comment? This action cannot be undone.", + ["comment.noComments"] = "No comments posted yet.", + ["comment.startConversation"] = "Start the conversation below!", + ["comment.writeComment"] = "Write a comment...", + ["theme.switchToDarkMode"] = "Switch to dark mode", + ["theme.switchToLightMode"] = "Switch to light mode", + ["page.loadingPage"] = "Loading page...", + ["page.notFound"] = "Page not found.", + ["report.projectHealthMatrixInsight"] = "Projects in the upper-right combine low progress with active issue pressure.", + ["report.actualEffortAboveEstimate"] = "Actual effort is {hours}h above estimate.", + ["report.actualEffortUnderEstimate"] = "Actual effort is {hours}h under estimate.", + ["report.utilizationAbovePlan"] = "Utilization is above plan; review estimates and blockers.", + ["report.utilizationBelowPlan"] = "Tracked actual effort is below plan; confirm work is being logged.", + ["report.utilizationCloseToPlan"] = "Utilization is close to the planning range.", + ["report.accuracyExceedingEstimate"] = "More issues are exceeding estimate than staying within it.", + ["report.accuracyWithinEstimate"] = "Most issues are within or under their estimates.", + ["report.remainingEstimate"] = "Remaining estimate", + ["report.overLabel"] = "Over", + ["report.overEstimateDesc"] = "over estimate", + ["report.legend"] = "Legend", + ["report.riskSeverity"] = "Risk severity", + ["report.lowUrgency"] = "Low urgency", + ["report.highUrgency"] = "High urgency", + ["report.lowRisk"] = "Low risk", + ["report.highRisk"] = "High risk", + ["report.noMatrixData"] = "No matrix data available.", + ["report.riskMatrix"] = "Risk matrix", + ["report.noProjects"] = "No projects", + ["report.urgencyLabel"] = "urgency", + ["report.projectsCount"] = "project(s)", + ["report.itemsCount"] = "item(s)", + ["report.medium"] = "Medium", + ["report.noComparisonData"] = "No comparison data available.", + ["report.projectHealthMatrixDesc"] = "Progress health against issue risk.", + ["report.completionVsRiskInsight"] = "Lower completion with higher issue pressure should be reviewed first.", + ["report.reviewNow"] = "Review now", + ["report.lowCompletionHighRisk"] = "Low completion · high risk", + ["report.watchClosely"] = "Watch closely", + ["report.highCompletionHighRisk"] = "High completion · high risk", + ["report.recoverPlan"] = "Recover plan", + ["report.lowCompletionLowRisk"] = "Low completion · low risk", + ["report.onTrack"] = "On track", + ["report.highCompletionLowRisk"] = "High completion · low risk", + ["report.percentComplete"] = "{percent}% complete", + ["report.color"] = "Color", + ["report.topAtRiskProjectsDesc"] = "Projects needing planning attention first.", + ["report.topAtRiskProjectsInsight"] = "Risk blends overdue, blocked, open issues, and project health.", + ["report.timelineSummaryDesc"] = "Project schedule window with completion overlay.", + ["report.timelineSummaryInsight"] = "This gives the planning horizon without leaving the report.", + ["report.issuesCompositionByProject"] = "Open, overdue, and blocking issues by project", + ["report.issuesCompositionByProjectDesc"] = "Issue composition across the project portfolio.", + ["report.issuesCompositionByProjectInsight"] = "Open work is normal; overdue and blocked work are the triage signal.", + ["report.noAtRiskProjects"] = "No at-risk projects in the current filter.", + ["report.needsSupport"] = "needs support", + ["report.tracked"] = "tracked", + ["report.completedTasks"] = "Completed tasks", + ["report.assignedTasks"] = "Assigned tasks", + ["report.capacityRisk"] = "Capacity risk", + ["report.noIssues"] = "No issues", + ["report.open"] = "open", + ["report.timeTrackingTitle"] = "Time & Effort Tracking Report", + ["report.timeTrackingHeading"] = "Time & Effort Tracking", + ["report.timeTrackingDesc"] = "Review estimated hours, completion timelines, and resource effort distribution across issues.", + ["report.overdueTasksPageTitle"] = "Overdue & Delayed Tasks Report", + ["report.overdueTasksHeading"] = "Overdue & Delayed Tasks", + ["report.overdueTasksDesc"] = "Track tasks that have passed their due date or are at risk of delay.", + ["report.highPriorityOverdue"] = "High Priority Overdue", + ["report.highPriorityOverdueDesc"] = "Critical work that needs immediate support.", + ["report.overdueInsight"] = "Phrase these as capacity risks so teams can unblock work, not assign blame.", + ["report.dueWithin3Days"] = "Due Within 3 Days", + ["report.avgDaysOverdue"] = "Avg Days Overdue", + ["report.allDelayed"] = "All Delayed", + ["report.overduePastDue"] = "Overdue (Past Due)", + ["report.showingDelayedTasks"] = "Showing", + ["report.delayedOrOverdueTasks"] = "delayed / overdue task(s)", + ["report.delayEscalation"] = "Delay / Escalation", + ["report.blockedBy"] = "Blocked by: ", + ["report.escalation"] = "Escalation: ", + ["report.capacitySupportNeeded"] = "Capacity support may be needed", + ["report.delayedTasksCount"] = "delayed task(s)", + ["report.needsAction"] = "needs action", + ["report.notOverdueYet"] = "not overdue yet", + ["report.earlyDelay"] = "early delay", + ["report.noOverdueTasks"] = "No overdue tasks", + ["report.daysOverdue"] = "Days Overdue", + ["common.searchByTaskAssigneeProject"] = "Search by title, assignee, or project...", + ["notification.system"] = "System", + ["notification.notifications"] = "Notifications", + ["notification.unread"] = "unread", + ["notification.clearRead"] = "Clear read", + ["notification.loading"] = "Loading notifications...", + ["notification.noneYet"] = "No notifications yet", + ["notification.emptyState"] = "Your task alerts will show up here.", + ["notification.clearReadTitle"] = "Clear read notifications?", + ["notification.clearReadMessage"] = "This will permanently remove all read notifications from your list.", + ["report.projectProgressTitle"] = "Project Progress Report", + ["report.projectProgressHeading"] = "Project Progress", + ["report.projectProgressDesc"] = "View project health using both planning tasks and daily execution issues.", + ["report.searchProject"] = "Search Project", + ["report.periodProjectStart"] = "Period (Project Start Date)", + ["report.taskStatusCompletion"] = "Task Status Completion", + ["report.totalIssues"] = "Total Issues", + ["report.overdueIssues"] = "Overdue Issues", + ["report.blockedIssues"] = "Blocked Issues", + ["report.overdueIssuesNeedAttention"] = "overdue issues need attention.", + ["report.issuesAreBlocked"] = "issues are blocked.", + ["report.actualHoursAre"] = "Actual hours are", + ["report.against"] = "against", + ["report.searchEmployee"] = "Search Employee", + ["report.employeeWorkloadTitle"] = "Employee Workload & Performance", + ["report.employeeWorkloadHeading"] = "Employee Workload & Performance", + ["report.employeeWorkloadDesc"] = "Monitor issue workload, execution health, and actual effort.", + ["report.delaysByProject"] = "Delays by project", + ["report.delaysByProjectDesc"] = "Project areas where delay concentration is highest.", + ["report.delaysByProjectInsight"] = "Use this to rebalance planning pressure across projects.", + ["report.delaysByAssignee"] = "Delays by assignee", + ["report.delaysByAssigneeDesc"] = "People who may need support or scope adjustment.", + ["report.delaysByAssigneeInsight"] = "Support signal only: high counts should trigger help, not blame.", + ["report.pastDueDate"] = "Past due date", + ["report.highPriorityDueSoon"] = "High priority due soon", + ["report.severityVsDaysOverdue"] = "Severity vs days overdue", + ["report.severityVsDaysOverdueDesc"] = "High priority and older delays sit in the upper-right risk zone.", + ["report.severityMatrixInsight"] = "The matrix combines priority severity with age of delay to show triage order.", + ["report.estVsActualEffort"] = "Estimated vs actual effort", + ["report.estVsActualEffortDesc"] = "Are plans matching delivery effort?", + ["report.actual"] = "Actual", + ["report.estimated"] = "Estimated", + ["report.utilizationGaugeDesc"] = "Actual hours as a share of estimated effort.", + ["report.varianceLabel"] = "variance", + ["report.usedLabel"] = "used", + ["report.estimateAccuracyDesc"] = "How many items stayed within estimate?", + ["report.onOrUnder"] = "on / under", + ["report.issuesLabel"] = "issues", + ["report.effortByProjectDesc"] = "Actual hours distributed across active projects.", + ["report.effortByProjectInsight"] = "Use this to spot projects consuming more delivery capacity than expected.", + ["report.effortLeaderboardDesc"] = "People carrying the most tracked effort.", + ["report.effortLeaderboardInsight"] = "Names highlight capacity allocation, not performance judgement.", + ["report.noEmployeeEffort"] = "No employee effort data available.", + ["report.openIssuesSegment"] = "Open issues", + ["report.overdueIssuesSegment"] = "Overdue issues", + ["report.blockedIssuesSegment"] = "Blocked issues", + ["project.assignDescription"] = "Assign team members to projects to control their task visibility.", + ["project.assignmentsSaved"] = "Project assignments saved successfully!", + ["project.failedSaveAssignment"] = "Failed to save assignment. Please try again.", + ["project.selectProjectPrompt"] = "Please select a project from the left panel to manage user assignments.", + ["issue.addIssueDescription"] = "Create a one-day work issue for a task and record the hours you spent.", + ["issue.untitledIssue"] = "Untitled issue", + ["issue.created"] = "Issue created successfully!", + ["issue.updated"] = "Issue updated successfully!", + ["issue.deleted"] = "Issue deleted successfully!", + ["issue.saveConfirmMessage"] = "Are you sure you want to save these issue changes?", + ["issue.whatDidYouWorkOn"] = "What did you work on?", + ["issue.addShortNote"] = "Add a short note about the work you did...", + ["issue.exampleEstimatedHours"] = "e.g. 4", + ["issue.exampleActualHours"] = "e.g. 7", + ["issue.actualHoursHint"] = "Use the total hours you spent today. For your standard day, this is usually 7 hours.", + ["issue.blockedByPlaceholder"] = "Dependency, person, or team", + ["issue.delayReasonPlaceholder"] = "Why is this issue delayed?", + ["issue.teamLead"] = "Team Lead", + ["issue.pm"] = "PM", + ["issue.manager"] = "Manager", + ["translation.generateEnglishSpelling"] = "Generate English spelling", + ["translation.generateBurmeseName"] = "မြန်မာအမည် ရေးသားပေးရန်", + ["translation.generateEnglish"] = "Generate English", + ["translation.generateBurmese"] = "မြန်မာဘာသာ ပြန်ဆိုရန်", + ["translation.englishDetected"] = "This text appears to be English. Move it to the English field?", + ["issue.failedCreate"] = "Failed to create issue. Please try again.", + ["issue.dateRangeInvalid"] = "The due date cannot be earlier than the start date.", + ["issue.loadFailed"] = "Unable to load issues. Please try again.", + ["issue.updateFailed"] = "Unable to update the issue. Please try again.", + ["issue.deleteFailed"] = "Unable to delete the issue. Please try again.", + ["common.loadFailed"] = "Unable to load the requested data. Please try again.", + ["issue.titlePlaceholder"] = "Issue title", + ["issue.descriptionPlaceholder"] = "Describe the work...", + ["role.namePlaceholder"] = "e.g. Manager, Viewer", + ["role.descriptionPlaceholder"] = "What can this role do?", + ["user.editUser"] = "Edit User", + ["user.createUser"] = "Create User?", + ["user.updateUser"] = "Update User?", + ["user.deleteUser"] = "Delete User?", + ["user.updateUserConfirm"] = "Are you sure you want to update this user?", + ["user.createUserConfirm"] = "Are you sure you want to create this user?", + ["user.deleteUserConfirm"] = "Are you sure you want to delete this user? This action cannot be undone.", + ["user.cannotDeleteSelf"] = "You cannot delete your own account.", + ["result.userCreated"] = "User created successfully!", + ["result.userUpdated"] = "User updated successfully!", + ["result.userDeleted"] = "User deleted successfully!", + ["result.failedToDeleteUser"] = "Failed to delete user.", + ["result.userSaveFailed"] = "Unable to save the user. Please try again.", + ["report.assignedToMe"] = "Assigned to me", + ["report.assignedToMyTeam"] = "Assigned to my team", + ["report.total"] = "Total", + ["common.rememberMe"] = "Remember me", + ["auth.needAccount"] = "Need an account?", + ["auth.resetPassword"] = "Reset password", + ["result.signedOut"] = "You have been signed out successfully.", + ["result.passwordUpdated"] = "Password updated successfully. Please sign in with your new password.", + ["result.apiUnavailable"] = "Unable to reach the API server. Start TaskTrackingSystem.WebApi and try again.", + ["result.registrationFailed"] = "Registration failed. Please try again.", + ["result.resetPasswordFailed"] = "Failed to reset password.", + ["result.invalidCredentials"] = "Invalid username/email or password.", + ["result.noPermissionUpdateTasks"] = "You do not have permission to update tasks.", + ["result.usernameTaken"] = "Username is already taken.", + ["result.emailRegistered"] = "Email is already registered.", + ["result.noSystemRoles"] = "No system roles configured. Registration failed.", + ["result.invalidRecoveryCode"] = "Invalid recovery code.", + ["result.accountNotFound"] = "Account not found.", + ["report.1to3Days"] = "1-3d", + ["report.4to7Days"] = "4-7d", + ["report.8PlusDays"] = "8d+", + ["report.1to7Late"] = "1-7d late", + ["report.8PlusLate"] = "8d+ late", + ["report.agingBuckets"] = "Aging buckets", + ["report.agingBucketsDesc"] = "How long delayed work has been waiting.", + ["report.allReportedIssues"] = "All reported issues", + ["report.avgRate"] = "avg rate", + ["report.assignedColumn"] = "Assigned", + ["report.openColumn"] = "Open", + ["report.overdueColumn"] = "Overdue", + ["report.capacitySupportDesc"] = "Highest support signals, phrased as capacity risk.", + ["report.capacitySupportInsight"] = "Use this to route help and remove blockers, not to rank people negatively.", + ["report.completedIssues"] = "Completed issues", + ["report.completedWorkLeading"] = "Completed work is leading the current filtered set.", + ["report.completion"] = "completion", + ["report.completionHealthy"] = "Completion is healthy for the current filter.", + ["report.completionImprove"] = "Completion has room to improve; inspect open issues and overdue work.", + ["report.completionMovement"] = "Completion movement", + ["report.completionMovementDesc"] = "Recently completed tasks by creation month.", + ["report.completionPatternDesc"] = "Completion rate across visible employees.", + ["report.completionPatternInsight"] = "A quick view of whether completion is clustered or balanced.", + ["report.completionVsRiskDesc"] = "Each project plotted by completion and issue pressure.", + ["report.couldNotLoad"] = "Task report could not load.", + ["report.created"] = "Created", + ["report.critical"] = "critical", + ["report.criticalTasksNeedAttention"] = "critical tasks need attention.", + ["report.currentlyBeingWorked"] = "Currently being worked", + ["report.delayReason"] = "Delay Reason", + ["report.dueLater"] = "Due later", + ["report.dueSoon"] = "Due soon", + ["report.employeesWithOverdueLoad"] = "Employees with overdue issue load.", + ["report.executionRisk"] = "Execution risk in the filtered issue set.", + ["report.focusStandup"] = "Focus standup discussion here first.", + ["report.highPriorityTasks"] = "high-priority tasks", + ["report.highPriorityVisible"] = "high-priority tasks should stay visible during planning review.", + ["report.issueLifecycleFunnel"] = "Issue lifecycle funnel", + ["report.issueLifecycleFunnelDesc"] = "How issues move from backlog to resolution.", + ["report.issueLifecycleInsight"] = "A large in-progress or blocked band is a daily execution signal.", + ["report.issuesByAssignee"] = "Issues by assignee", + ["report.issuesByAssigneeDesc"] = "Support needs by assignee.", + ["report.issuesByAssigneeInsight"] = "Use as a support signal, especially when overdue or blocked items cluster.", + ["report.issuesByProject"] = "Issues by project", + ["report.issuesByProjectDesc"] = "Where issue volume is concentrated.", + ["report.issuesByProjectInsight"] = "Useful for project-level triage and planning health.", + ["report.issuesStillOpen"] = "issues are still open across the selected scope.", + ["report.issueStatusDonut"] = "Issue status donut", + ["report.issueStatusDonutDesc"] = "Current status distribution, including overdue pressure.", + ["report.loadError"] = "The server returned an error while loading task report data.", + ["report.loadingIssueReport"] = "Loading issue report...", + ["report.loadingOverdueTasks"] = "Loading overdue tasks...", + ["report.loadingTimesheet"] = "Loading timesheet data...", + ["report.manager"] = "Manager", + ["report.needsUnblockReview"] = "Needs unblock review", + ["report.noAssigneeDelay"] = "No assignee delay concentration found.", + ["report.noCapacityRisks"] = "No capacity risks in the current filter.", + ["report.noHighPriority"] = "No high-priority tasks in the current filter.", + ["report.noOverdueSupport"] = "No overdue support needs found.", + ["report.noProjectDelay"] = "No project delay concentration found.", + ["report.openIssuesAttached"] = "open issues are attached to reported tasks.", + ["report.overdueSupportInsight"] = "Treat overdue counts as support requests.", + ["report.overdueTasks"] = "overdue tasks", + ["report.pm"] = "PM", + ["report.priorityDistribution"] = "Priority distribution", + ["report.priorityDistributionDesc"] = "Planning pressure by task priority.", + ["report.resolutionAging"] = "Resolution aging", + ["report.resolutionAgingDesc"] = "How close issues are to or past due date.", + ["report.resolutionAgingInsight"] = "Older overdue buckets should be cleared before new scope expands.", + ["report.resolvedIssuesLeading"] = "Resolved issues are leading this filtered set.", + ["report.risk"] = "risk", + ["report.riskScore"] = "Risk score", + ["report.riskIssues"] = "risk issues", + ["report.supportSignal"] = "Support signal", + ["report.tasks"] = "tasks", + ["report.tasksHaveOverdueIssues"] = "tasks have overdue issues.", + ["report.tasksStillMoving"] = "tasks are still moving through execution.", + ["report.taskStatusMix"] = "Task status mix", + ["report.taskStatusMixDesc"] = "Where execution currently sits across the task lifecycle.", + ["report.tasksWithMostIssues"] = "Tasks with most open issues", + ["report.tasksWithMostIssuesDesc"] = "Execution blockers that may need issue cleanup before progress moves.", + ["report.tasksWithMostIssuesInsight"] = "Use this to decide where issue triage creates the fastest task movement.", + ["report.teamExecutionBalance"] = "Team execution balance by issue state.", + ["report.teamLead"] = "Team Lead", + ["report.topProjectRisk"] = "Top project risk", + ["report.topProjectRiskDesc"] = "Projects with the highest task and issue pressure.", + ["report.topProjectRiskInsight"] = "Score combines task volume, open issues, overdue issues, and critical task health.", + ["report.unresolvedIssues"] = "Unresolved issues", + ["report.workloadHeatmapDesc"] = "Capacity signals across assigned work, open issues, overdue issues, and hours.", + ["report.workloadHeatmapInsight"] = "Darker cells mean someone may need support or scope adjustment.", + ["report.funnelBaselineNote"] = "Width is measured against {label} ({value}). Zero-value stages keep an empty track with no colored fill.", + ["report.noStage"] = "No {stage}", + ["report.funnelTooltip"] = "{label}: {value} ({percent}% of baseline)", + ["report.noAgingData"] = "No aging data available.", + ["report.noChartData"] = "No data available.", + ["report.noFunnelData"] = "No funnel data available.", + ["report.noWorkloadData"] = "No workload data available.", + ["report.noRiskItems"] = "No risk items to show.", + ["report.notEnoughTrendData"] = "Not enough data for a trend yet.", + ["report.stackedProgress"] = "Stacked progress", + ["report.statusDistribution"] = "Status distribution" + }; + + 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.ok"] = "အိုကေ", + ["common.score"] = "အမှတ်", + ["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.refresh"] = "ပြန်လည်ရယူမည်", + ["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.systemAdmin"] = "စနစ်အုပ်ချုပ်သူ", + ["common.role"] = "အခန်းကဏ္ဍ", + ["common.module"] = "အပိုင်း", + ["common.name"] = "အမည်", + ["error.title"] = "အမှား", + ["error.heading"] = "အမှား", + ["error.processingRequest"] = "တောင်းဆိုချက်ကို လုပ်ဆောင်နေစဉ် အမှားတစ်ခု ဖြစ်ပွားခဲ့ပါသည်။", + ["error.requestId"] = "တောင်းဆိုချက်အမှတ်", + ["error.developmentMode"] = "ဖွံ့ဖြိုးရေးမုဒ်", + ["error.developmentDetails"] = "ဖွံ့ဖြိုးရေးပတ်ဝန်းကျင်သို့ ပြောင်းလဲပါက အမှားအသေးစိတ်များကို ပိုမိုပြသပါမည်။", + ["error.developmentWarning"] = "ဖြန့်ချိထားသော အက်ပ်များတွင် ဖွံ့ဖြိုးရေးပတ်ဝန်းကျင်ကို မဖွင့်ထားသင့်ပါ။", + ["error.developmentSensitive"] = "ထိုသို့ပြုလုပ်ပါက အသုံးပြုသူများထံ အထိခိုက်လွယ်သော အမှားအသေးစိတ်များ ပေါ်လာနိုင်ပါသည်။", + ["error.developmentInstructions"] = "ဒေသတွင်း စမ်းသပ်ရန် ASPNETCORE_ENVIRONMENT ကို Development ဟု သတ်မှတ်ပြီး အက်ပ်ကို ပြန်လည်စတင်ပါ။", + ["issue.titlePlaceholder"] = "ပြဿနာခေါင်းစဉ်", + ["issue.descriptionPlaceholder"] = "လုပ်ဆောင်ချက်ကို ဖော်ပြပါ...", + ["role.namePlaceholder"] = "ဥပမာ - မန်နေဂျာ၊ ကြည့်ရှုသူ", + ["role.descriptionPlaceholder"] = "ဤအခန်းကဏ္ဍက ဘာများ လုပ်ဆောင်နိုင်ပါသလဲ။", + ["issue.loadFailed"] = "ပြဿနာများကို ဖတ်၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["issue.updateFailed"] = "ပြဿနာကို ပြင်ဆင်၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["issue.deleteFailed"] = "ပြဿနာကို ဖျက်၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["common.loadFailed"] = "တောင်းဆိုထားသော အချက်အလက်များကို ဖတ်၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["common.firstName"] = "ရှေ့အမည်", + ["common.lastName"] = "နောက်အမည်", + ["common.username"] = "အသုံးပြုသူအမည်", + ["common.phone"] = "ဖုန်း", + ["common.phoneNumber"] = "ဖုန်းနံပါတ်", + ["common.keepHere"] = "ဤနေရာတွင် ထားမည်", + ["common.moveToEnglish"] = "အင်္ဂလိပ်အကွက်သို့ ရွှေ့မည်", + ["common.generating"] = "ဖန်တီးနေသည်...", + ["common.translationNotConfigured"] = "ဘာသာပြန်ဝန်ဆောင်မှုကို မသတ်မှတ်ရသေးပါ။", + ["common.complete"] = "ပြည့်စုံသည်", + ["common.aiGenerated"] = "AI ဖြင့် ဖန်တီးထားသည်", + ["common.needsReview"] = "ပြန်လည်စစ်ဆေးရန် လိုအပ်သည်", + ["common.missing"] = "မပြည့်စုံသေးပါ", + ["common.translationReview"] = "AI ဖြင့် ဖန်တီးထားသော စာသားကို ပြန်လည်စစ်ဆေးနိုင်ပါပြီ။ မသိမ်းမီ စစ်ဆေးပါ။", + ["common.description"] = "ဖော်ပြချက်", + ["common.date"] = "ရက်စွဲ", + ["common.startDate"] = "စတင်ရက်", + ["common.endDate"] = "ပြီးဆုံးရက်", + ["common.dueDate"] = "ပြီးဆုံးရန်ရက်", + ["common.due"] = "ပြီးဆုံးရက်", + ["common.hours"] = "နာရီ", + ["common.hoursAbbrev"] = "နာရီ", + ["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.error"] = "အမှား", + ["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.searchUsers"] = "အသုံးပြုသူများ ရှာရန်", + ["common.searchByName"] = "အမည်ဖြင့် ရှာပါ...", + ["common.newUser"] = "အသုံးပြုသူအသစ်", + ["common.taskCode"] = "လုပ်ငန်းကုဒ်", + ["common.selectedMember"] = "ရွေးထားသော အဖွဲ့ဝင်", + ["common.loadingSpinner"] = "ဖွင့်နေသည်...", + ["common.loadingIssues"] = "ပြဿနာများ ဖွင့်နေသည်...", + ["common.loadingTasksAndUsers"] = "လုပ်ငန်းများနှင့် အသုံးပြုသူများ ဖွင့်နေသည်...", + ["common.loadingProjectsAndUsers"] = "စီမံကိန်းများနှင့် အသုံးပြုသူများ ဖွင့်နေသည်...", + ["common.loadingProjectMembers"] = "စီမံကိန်းအဖွဲ့ဝင်များ ဖွင့်နေသည်...", + ["common.loadingMembers"] = "အဖွဲ့ဝင်များ ဖွင့်နေသည်...", + ["common.noResults"] = "ရလဒ်မတွေ့ပါ။", + ["pagination.showing"] = "ပြသနေသည်", + ["pagination.of"] = "စုစုပေါင်း", + ["pagination.results"] = "ရလဒ်", + ["pagination.rowsPerPage"] = "တစ်မျက်နှာအရေအတွက်", + ["pagination.firstPage"] = "ပထမစာမျက်နှာ", + ["pagination.previousPage"] = "ရှေ့စာမျက်နှာ", + ["pagination.nextPage"] = "နောက်စာမျက်နှာ", + ["pagination.lastPage"] = "နောက်ဆုံးစာမျက်နှာ", + ["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"] = "လုပ်ငန်း မရွေးထားပါ", + ["task.selectTaskPrompt"] = "အဖွဲ့ဝင်တစ်ဦးထံ တာဝန်ချထားရန် ဘယ်ဘက်အကန့်မှ လုပ်ငန်းတာဝန်တစ်ခုကို ရွေးချယ်ပါ။", + ["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.assignmentSaved"] = "တာဝန်ချထားမှုကို အောင်မြင်စွာ သိမ်းဆည်းပြီးပါပြီ။", + ["task.assigneeUpdated"] = "အလုပ်တာဝန်ယူသူကို အောင်မြင်စွာ ပြင်ဆင်ပြီးပါပြီ။", + ["task.failedUpdateAssignee"] = "အလုပ်တာဝန်ယူသူကို ပြင်ဆင်၍ မရပါ။", + ["task.failedLoadProjectMembers"] = "စီမံကိန်းအဖွဲ့ဝင်များကို ဖွင့်၍ မရပါ။", + ["task.assigneeMustBelong"] = "အလုပ်တာဝန်ယူသူသည် ရွေးထားသော စီမံကိန်း၏ အဖွဲ့ဝင် ဖြစ်ရပါမည်။", + ["task.titleRequiredEitherLanguage"] = "လုပ်ငန်းခေါင်းစဉ်ကို အနည်းဆုံး ဘာသာစကားတစ်ခုဖြင့် ထည့်ရပါမည်။", + ["task.saveAssignmentTitle"] = "တာဝန်ချမှု သိမ်းမည်လား။", + ["task.saveAssignmentConfirm"] = "ဤအလုပ်တာဝန်ချထားမှုကို သိမ်းဆည်းရန် သေချာပါသလား။", + ["project.saveAssignmentTitle"] = "တာဝန်ချမှု သိမ်းမည်လား။", + ["project.saveAssignmentConfirm"] = "ဤစီမံကိန်းအဖွဲ့ဝင် တာဝန်ချထားမှုများကို သိမ်းဆည်းရန် သေချာပါသလား။", + ["issue.yesSave"] = "ဟုတ်ကဲ့၊ သိမ်းမည်", + ["common.saveChanges"] = "ပြောင်းလဲချက်များ သိမ်းမည်", + ["common.addIssue"] = "ပြဿနာ ထည့်မည်", + ["common.addIssueTitle"] = "ပြဿနာ ထည့်မည်လား?", + ["issue.saveAddConfirmMessage"] = "ဤပြဿနာကို သိမ်းဆည်းရန် သေချာပါသလား။", + ["issue.yesAdd"] = "ဟုတ်ကဲ့၊ ထည့်မည်", + ["common.editIssue"] = "ပြဿနာ ပြင်မည်", + ["common.removeOwnerFromTask"] = "ဤလုပ်ငန်းမှ ပိုင်ရှင်ကို ဖယ်ရှားမည်", + + ["page.dashboard"] = "ပင်မစာမျက်နှာ", + ["page.managerDashboard"] = "မန်နေဂျာ ပင်မစာမျက်နှာ", + ["page.employeeDashboard"] = "ဝန်ထမ်း ပင်မစာမျက်နှာ", + ["page.projects"] = "စီမံကိန်းများ", + ["page.reports"] = "အစီရင်ခံစာများ", + ["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"] = "ဝင်ခွင့်အုပ်စု ပုံစံများနှင့် ခွင့်ပြုချက်များကို ကြည့်ပါ။", + + ["dashboard.overview"] = "အနှစ်ချုပ်", + ["dashboard.welcomeBack"] = "ပြန်လည်ကြိုဆိုပါတယ်၊ {name}", + ["dashboard.adminIntro"] = "စီမံကိန်းများ၊ အသုံးပြုသူများနှင့် အဖွဲ့တိုးတက်မှုကို တစ်ချက်ကြည့်နိုင်သည်။", + ["dashboard.totalUsers"] = "အသုံးပြုသူ စုစုပေါင်း", + ["dashboard.totalProjects"] = "စီမံကိန်း စုစုပေါင်း", + ["dashboard.activeTasks"] = "လက်ရှိအလုပ်များ", + ["dashboard.completionRate"] = "ပြီးစီးမှုနှုန်း", + ["dashboard.projectActivity"] = "စီမံကိန်း လှုပ်ရှားမှု", + ["dashboard.projectActivityDesc"] = "လအလိုက် လက်ရှိနှင့် ပြီးစီးသောအလုပ်များ။", + ["dashboard.taskDistribution"] = "အလုပ်များ ခွဲဝေမှု", + ["dashboard.totalTasks"] = "အလုပ် စုစုပေါင်း {count} ခု", + ["dashboard.completedCount"] = "ပြီးစီးပြီး: {count}", + ["dashboard.activeCount"] = "လက်ရှိ: {count}", + ["dashboard.allProjects"] = "စီမံကိန်းအားလုံး", + ["dashboard.projectsSnapshot"] = "လက်ရှိစီမံကိန်းစာရင်းကို အမြန်ကြည့်ရန်။", + ["dashboard.viewAll"] = "အားလုံးကြည့်ရန်", + ["dashboard.tasksUnit"] = "အလုပ်", + ["dashboard.calendar"] = "ပြက္ခဒိန်", + ["dashboard.calendarDesc"] = "ယနေ့နှင့် မကြာမီစမည့် စီမံကိန်းများ။", + ["dashboard.events"] = "ဖြစ်ရပ်များ", + ["dashboard.projectTimeline"] = "စီမံကိန်း အချိန်ဇယား", + ["dashboard.projectTimelineDesc"] = "စီမံကိန်းအားလုံးကို Gantt ပုံစံဖြင့် ကြည့်ပြီး အောက်တွင် လအပိုင်းအခြားကို ပြင်ဆင်ပါ။", + ["dashboard.from"] = "မှ", + ["dashboard.to"] = "အထိ", + ["dashboard.noProjectsToDisplay"] = "ပြသရန် စီမံကိန်း မရှိပါ။", + ["dashboard.thisMonth"] = "ယခုလ", + ["dashboard.vsLastMonth"] = "ပြီးခဲ့သည့်လနှင့် နှိုင်းယှဉ်", + ["dashboard.upcoming"] = "မကြာမီစမည်", + ["dashboard.active"] = "လက်ရှိ", + ["dashboard.queued"] = "စောင့်ဆိုင်း", + ["dashboard.soon"] = "မကြာမီ", + ["dashboard.late"] = "နောက်ကျ", + ["dashboard.employeeTitle"] = "ဝန်ထမ်း ပင်မစာမျက်နှာ", + ["dashboard.teamMember"] = "အဖွဲ့ဝင်", + ["dashboard.myWork"] = "ကျွန်ုပ်၏အလုပ်များ", + ["dashboard.employeeIntro"] = "အလုပ်များ၊ နောက်ဆုံးရက်များနှင့် သင့်တိုးတက်မှုကို တစ်နေရာတည်းတွင် ကြည့်နိုင်သည်။", + ["dashboard.viewTasks"] = "အလုပ်များ ကြည့်ရန်", + ["dashboard.loadingDashboard"] = "ပင်မစာမျက်နှာ ဖတ်နေသည်...", + ["dashboard.tasksInProgressDesc"] = "လက်ရှိလုပ်ဆောင်နေသော အလုပ်များ", + ["dashboard.noTasksInProgress"] = "လက်ရှိလုပ်ဆောင်နေသော အလုပ် မရှိပါ။", + ["dashboard.todoUpcoming"] = "လုပ်ရန်နှင့် မကြာမီလာမည့်အလုပ်များ", + ["dashboard.todoUpcomingDesc"] = "စတင်လုပ်ရန် သို့မဟုတ် မကြာမီလာမည့် အလုပ်များ", + ["dashboard.noUpcomingTasks"] = "မကြာမီလာမည့် အလုပ် မတွေ့ပါ။", + ["dashboard.overdueDesc"] = "နောက်ဆုံးရက် ကျော်လွန်နေသော အလုပ်များ", + ["dashboard.noOverdueTasks"] = "နောက်ကျနေသော အလုပ် မရှိပါ။ ကောင်းပါတယ်။", + ["dashboard.relatedTasks"] = "ဆက်စပ်အလုပ်များ", + ["dashboard.tasksMarkedOn"] = "{date} တွင် မှတ်ထားသော အလုပ်များ", + ["dashboard.noTasksForDay"] = "ဤနေ့အတွက် သတ်မှတ်ထားသော အလုပ် မရှိပါ။", + ["dashboard.dueSoon"] = "မကြာမီ နောက်ဆုံးရက်ရောက်မည်", + ["dashboard.managerTitle"] = "မန်နေဂျာ ပင်မစာမျက်နှာ", + ["dashboard.teamView"] = "အဖွဲ့ မြင်ကွင်း", + ["dashboard.myProjects"] = "ကျွန်ုပ်၏ စီမံကိန်းများ", + ["dashboard.managerIntro"] = "သင့်အဖွဲ့၊ စီမံကိန်းအခြေအနေနှင့် အပတ်စဉ်အလုပ်တိုးတက်မှုကို တစ်နေရာတည်းတွင် ကြည့်နိုင်သည်။", + ["dashboard.pendingTasksNotice"] = "အလုပ် {count} ခု စောင့်ဆိုင်းနေဆဲဖြစ်သည်။", + ["dashboard.taskReport"] = "အလုပ် အစီရင်ခံစာ", + ["dashboard.weeklyTaskProgress"] = "အပတ်စဉ် အလုပ်တိုးတက်မှု", + ["dashboard.weeklyTaskProgressDesc"] = "အပတ်အလိုက် ပြီးစီးပြီးနှင့် စောင့်ဆိုင်းနေသော အလုပ်များ", + ["dashboard.teamProgress"] = "အဖွဲ့ တိုးတက်မှု", + ["dashboard.teamProgressDesc"] = "လက်ရှိအလုပ်ရှိသော အဖွဲ့ဝင်များ", + ["dashboard.noAssignedWork"] = "ခွဲဝေထားသော အလုပ် မရှိသေးပါ။", + ["dashboard.upcomingDeadlines"] = "မကြာမီရောက်မည့် နောက်ဆုံးရက်များ", + ["dashboard.projectsEndingSoon"] = "မကြာမီပြီးဆုံးမည့် စီမံကိန်းများ", + ["dashboard.ends"] = "ပြီးဆုံးရက်", + ["dashboard.noUpcomingDeadlines"] = "မကြာမီရောက်မည့် နောက်ဆုံးရက် မရှိပါ။", + ["dashboard.clickViewTasks"] = "\"အလုပ်များ ကြည့်ရန်\" ကိုနှိပ်ပြီး အသေးစိတ်ကြည့်ပါ", + ["dashboard.complete"] = "ပြီးစီးမှု", + ["dashboard.noProjectsForAccount"] = "ဤအကောင့်အတွက် စီမံကိန်း မရှိပါ။", + ["dashboard.activeProjects"] = "လက်ရှိ စီမံကိန်းများ", + ["dashboard.totalTasksMetric"] = "အလုပ် စုစုပေါင်း", + ["dashboard.teamMembers"] = "အဖွဲ့ဝင်များ", + ["dashboard.overdueTasks"] = "နောက်ကျနေသော အလုပ်များ", + ["dashboard.allActive"] = "အားလုံး လက်ရှိ", + ["dashboard.acrossProjects"] = "စီမံကိန်းများအတွင်း", + ["dashboard.yourTeam"] = "သင့်အဖွဲ့", + ["dashboard.needsAttention"] = "အာရုံစိုက်ရန် လိုသည်", + ["dashboard.atRisk"] = "အန္တရာယ်ရှိ", + ["dashboard.pending"] = "စောင့်ဆိုင်းနေသည်", + ["dashboard.today"] = "ယနေ့", + ["dashboard.done"] = "ပြီးစီးပြီး", + ["dashboard.daysAgo"] = "လွန်ခဲ့သော {count} ရက်", + ["dashboard.daysLeft"] = "{count} ရက် ကျန်", + ["dashboard.dueToday"] = "ယနေ့ နောက်ဆုံးရက်", + + + ["status.todo"] = "လုပ်ရန်", + ["status.inprogress"] = "ဆောင်ရွက်နေသည်", + ["status.done"] = "ပြီးဆုံး", + ["status.overdue"] = "နောက်ကျ", + ["status.clear"] = "ရှင်း", + ["status.resolved"] = "ဖြေရှင်းပြီး", + ["status.uncompleted"] = "မပြီးသေး", + ["priority.low"] = "နိမ့်", + ["priority.medium"] = "အလယ်", + ["priority.high"] = "မြင့်", + ["status.active"] = "အသုံးပြုနေသည်", + ["status.inactive"] = "အသုံးမပြုတော့ပါ", + ["field.name"] = "အမည်", + ["field.title"] = "ခေါင်းစဉ်", + ["field.email"] = "အီးမေးလ်", + ["field.phone"] = "ဖုန်း", + ["field.created"] = "ဖန်တီးသည့်ရက်", + ["field.taskCode"] = "အလုပ်ကုဒ်", + ["field.ipAddress"] = "IP လိပ်စာ", + ["project.manageAll"] = "စီမံကိန်းအားလုံးကို စီမံပါ။", + ["project.employeesInProject"] = "ဤစီမံကိန်းရှိ ဝန်ထမ်းများ", + ["project.yourAssigned"] = "သင့်အား ခွဲဝေထားသော စီမံကိန်းများ။", + ["project.newProject"] = "စီမံကိန်းအသစ်", + ["project.editProject"] = "စီမံကိန်းပြင်ရန်", + ["project.createProject"] = "စီမံကိန်းဖန်တီးရန်", + ["project.untitledProject"] = "အမည်မရှိသော စီမံကိန်း", + ["project.noProjectsYet"] = "စီမံကိန်းမရှိသေးပါ", + ["project.createFirstProject"] = "စတင်ရန် ပထမဆုံးစီမံကိန်းကို ဖန်တီးပါ။", + ["project.noAssignedProjects"] = "သင့်အား စီမံကိန်းမခွဲဝေထားသေးပါ။", + ["project.projectName"] = "စီမံကိန်းအမည်", + ["project.projectDescriptionOptional"] = "စီမံကိန်းဖော်ပြချက် (မဖြည့်လည်းရသည်)", + ["project.updateTitle"] = "စီမံကိန်း အပ်ဒိတ်လုပ်မလား?", + ["project.updateConfirm"] = "ဤစီမံကိန်းကို အပ်ဒိတ်လုပ်ရန် သေချာပါသလား?", + ["project.createTitle"] = "စီမံကိန်း ဖန်တီးမလား?", + ["project.createConfirm"] = "ဤစီမံကိန်းကို ဖန်တီးရန် သေချာပါသလား?", + ["project.deleteTitle"] = "စီမံကိန်း ဖျက်မလား?", + ["project.deleteConfirm"] = "ဤစီမံကိန်းကို ဖျက်ရန် သေချာပါသလား? ဤလုပ်ဆောင်ချက်ကို ပြန်ပြင်၍ မရပါ။", + ["project.created"] = "စီမံကိန်းကို အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ။", + ["project.updated"] = "စီမံကိန်းကို အောင်မြင်စွာ အပ်ဒိတ်လုပ်ပြီးပါပြီ။", + ["project.deleted"] = "စီမံကိန်းကို အောင်မြင်စွာ ဖျက်ပြီးပါပြီ။", + ["project.failedDelete"] = "စီမံကိန်းကို ဖျက်၍ မရပါ။", + ["task.manageAll"] = "စီမံကိန်းအားလုံးရှိ အလုပ်များကို ကြည့်ရှု၍ စီမံပါ။", + ["task.filterByProject"] = "စီမံကိန်းဖြင့် စစ်ထုတ်ရန်", + ["task.yourAssigned"] = "သင့်အား ခွဲဝေထားသော အလုပ်များ။", + ["task.newTask"] = "အလုပ်အသစ်", + ["task.editTask"] = "အလုပ်ပြင်ရန်", + ["task.createTask"] = "အလုပ် ဖန်တီးမည်", + ["task.untitledTask"] = "ခေါင်းစဉ်မရှိသော အလုပ်", + ["task.noTasksYet"] = "အလုပ်မရှိသေးပါ", + ["task.createFirstTask"] = "စတင်ရန် ပထမဆုံးအလုပ်ကို ဖန်တီးပါ။", + ["task.taskTitle"] = "အလုပ်ခေါင်းစဉ်", + ["task.taskDescription"] = "အလုပ်ဖော်ပြချက်", + ["task.selectProject"] = "စီမံကိန်းရွေးပါ...", + ["task.loadingBoard"] = "ဘုတ်ကို ဖွင့်နေသည်...", + ["task.dragHint"] = "အခြေအနေပြောင်းရန် အလုပ်များကို ကော်လံများကြား ဆွဲရွှေ့ပါ။", + ["task.viewingArchived"] = "သိမ်းဆည်းထားသော အလုပ်များကို ကြည့်နေသည်။", + ["task.backToBoard"] = "ဘုတ်သို့ ပြန်သွားမည်", + ["task.archivedTasks"] = "သိမ်းဆည်းထားသော အလုပ်များ", + ["task.archived"] = "သိမ်းဆည်းပြီး", + ["task.dropTasksHere"] = "အလုပ်များကို ဤနေရာတွင် ချပါ", + ["task.noTasks"] = "အလုပ်မရှိပါ", + ["task.of"] = "မှ", + ["task.due"] = "ပြီးဆုံးရန်", + ["task.previousPage"] = "ယခင်စာမျက်နှာ", + ["task.nextPage"] = "နောက်စာမျက်နှာ", + ["task.createTitle"] = "အလုပ် ဖန်တီးမလား?", + ["task.createConfirm"] = "ဤအလုပ်ကို ဖန်တီးရန် သေချာပါသလား?", + ["task.updateTitle"] = "အလုပ် အပ်ဒိတ်လုပ်မလား?", + ["task.updateConfirm"] = "ဤအလုပ်ကို အပ်ဒိတ်လုပ်ရန် သေချာပါသလား?", + ["task.deleteTitle"] = "အလုပ် ဖျက်မလား?", + ["task.deleteConfirm"] = "ဤအလုပ်ကို ဖျက်ရန် သေချာပါသလား? ဤလုပ်ဆောင်ချက်ကို ပြန်ပြင်၍ မရပါ။", + ["task.archiveDoneTitle"] = "ပြီးဆုံးသော အလုပ်များ သိမ်းဆည်းမလား?", + ["task.archiveDoneInProject"] = "ဤစီမံကိန်းရှိ ပြီးဆုံးသော အလုပ် {count} ခုကို သိမ်းဆည်းမလား? သိမ်းဆည်းပြီးပါက လက်ရှိမြင်ကွင်းများတွင် ဖျောက်ထားမည်။", + ["task.archiveDoneAll"] = "မြင်နေရသော စီမံကိန်းအားလုံးရှိ ပြီးဆုံးသော အလုပ် {count} ခုကို သိမ်းဆည်းမလား? သိမ်းဆည်းပြီးပါက လက်ရှိမြင်ကွင်းများတွင် ဖျောက်ထားမည်။", + ["task.noDoneToArchive"] = "သိမ်းဆည်းရန် ပြီးဆုံးသော အလုပ်မရှိပါ။", + ["task.created"] = "အလုပ်ကို အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ!", + ["task.updated"] = "အလုပ်ကို အောင်မြင်စွာ အပ်ဒိတ်လုပ်ပြီးပါပြီ!", + ["task.deleted"] = "အလုပ်ကို အောင်မြင်စွာ ဖျက်ပြီးပါပြီ!", + ["task.failedCreate"] = "အလုပ်ဖန်တီး၍ မရပါ။", + ["task.failedDelete"] = "အလုပ်ဖျက်၍ မရပါ။", + ["task.failedMove"] = "အလုပ်ကို ရွှေ့၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["task.failedArchiveDone"] = "ပြီးဆုံးထားသော အလုပ်များကို သိမ်းဆည်း၍ မရပါ။", + ["task.archivedOneDone"] = "ပြီးဆုံးထားသော အလုပ် ၁ ခုကို သိမ်းဆည်းပြီးပါပြီ။", + ["task.archivedManyDone"] = "ပြီးဆုံးထားသော အလုပ် {count} ခုကို သိမ်းဆည်းပြီးပါပြီ။", + ["task.archive"] = "သိမ်းဆည်းမည်", + ["role.rolesAccess"] = "အခန်းကဏ္ဍနှင့် ဝင်ခွင့်များ", + ["role.rolesAccessDesc"] = "အခန်းကဏ္ဍများ၊ ဝင်ခွင့်များနှင့် ခွင့်ပြုချက်များကို စီမံပါ။", + ["role.newRole"] = "အခန်းကဏ္ဍအသစ်", + ["role.editRole"] = "အခန်းကဏ္ဍ ပြင်ရန်", + ["role.createNewRole"] = "အခန်းကဏ္ဍအသစ် ဖန်တီးရန်", + ["role.roleName"] = "အခန်းကဏ္ဍအမည်", + ["role.roleNameBurmese"] = "မြန်မာအမည်", + ["role.roleNameEnglish"] = "အင်္ဂလိပ်အမည်", + ["role.descriptionBurmese"] = "မြန်မာဖော်ပြချက်", + ["role.descriptionEnglish"] = "အင်္ဂလိပ်ဖော်ပြချက်", + ["role.searchByName"] = "အခန်းကဏ္ဍအမည်ဖြင့် ရှာပါ...", + ["role.loadingRoles"] = "အခန်းကဏ္ဍများ ဖွင့်နေသည်...", + ["role.noRolesYet"] = "အခန်းကဏ္ဍမရှိသေးပါ", + ["role.createFirstRole"] = "စတင်ရန် ပထမဆုံးအခန်းကဏ္ဍကို ဖန်တီးပါ။", + ["role.manageAccess"] = "ဝင်ခွင့် စီမံရန်", + ["role.access"] = "ဝင်ခွင့်", + ["role.assignAccess"] = "ဝင်ခွင့် ခွဲဝေရန်", + ["role.accessHint"] = "စာမျက်နှာများသည် ဘယ်နေရာသို့ ဝင်နိုင်သည်ကို ထိန်းချုပ်ပြီး လုပ်ဆောင်ချက်များသည် ဘာလုပ်နိုင်သည်ကို ထိန်းချုပ်သည်။", + ["role.createHint"] = "ဤအခန်းကဏ္ဍ မြင်နိုင်၊ လုပ်နိုင်သည့်အရာများကို ညာဘက်တွင် ရွေးပါ။ အခန်းကဏ္ဍနှင့်အတူ သိမ်းဆည်းမည်။", + ["role.descriptionPlaceholder"] = "ဤအခန်းကဏ္ဍသည် ဘာလုပ်နိုင်သလဲ?", + ["role.examplePlaceholder"] = "ဥပမာ မန်နေဂျာ၊ ကြည့်ရှုသူ", + ["role.updateTitle"] = "အခန်းကဏ္ဍ အပ်ဒိတ်လုပ်မလား?", + ["role.updateConfirm"] = "ဤအခန်းကဏ္ဍကို အပ်ဒိတ်လုပ်ရန် သေချာပါသလား?", + ["role.createTitle"] = "အခန်းကဏ္ဍ ဖန်တီးမလား?", + ["role.createConfirm"] = "ဤအခန်းကဏ္ဍကို ဖန်တီးရန် သေချာပါသလား?", + ["role.deleteTitle"] = "အခန်းကဏ္ဍ ဖျက်မလား?", + ["role.deleteConfirm"] = "ဤအခန်းကဏ္ဍကို ဖျက်ရန် သေချာပါသလား? ဤလုပ်ဆောင်ချက်ကို ပြန်ပြင်၍ မရပါ။", + ["role.cannotDeleteTitle"] = "အခန်းကဏ္ဍကို ဖျက်၍မရပါ", + ["role.cannotDeleteUsers"] = "ဤအခန်းကဏ္ဍတွင် အသုံးပြုသူ {count} ယောက် ရှိသောကြောင့် ဖျက်၍မရပါ။", + ["role.created"] = "အခန်းကဏ္ဍကို အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ။", + ["role.updated"] = "အခန်းကဏ္ဍကို အောင်မြင်စွာ အပ်ဒိတ်လုပ်ပြီးပါပြီ။", + ["role.deleted"] = "အခန်းကဏ္ဍကို အောင်မြင်စွာ ဖျက်ပြီးပါပြီ။", + ["role.failedDelete"] = "အခန်းကဏ္ဍကို ဖျက်၍ မရပါ။", + ["role.saveAccess"] = "အသုံးပြုခွင့် သိမ်းမည်", + ["role.accessConfigured"] = "အသုံးပြုခွင့်နှင့် ခွင့်ပြုချက်များကို သတ်မှတ်ပါ", + ["role.permissionsHint"] = "မီနူးများသည် စာမျက်နှာဖွင့်ခွင့်ဖြစ်ပြီး ခွင့်ပြုချက်များသည် လုပ်ဆောင်ချက်များကို ဖွင့်ပေးသည်။", + ["role.changeApplyAfterSave"] = "သိမ်းပြီးနောက် ပြောင်းလဲချက်များ သက်ရောက်ပါမည်။", + ["role.noAccessItemsFound"] = "ဒေတာဘေ့စ်တွင် အသုံးပြုခွင့် အချက်အလက်များ မတွေ့ပါ။", + ["role.noPagesMatchSearch"] = "ရှာဖွေမှုနှင့် ကိုက်ညီသော စာမျက်နှာ သို့မဟုတ် ခွင့်ပြုချက် မရှိပါ။", + ["role.pagePermissionHint"] = "စာမျက်နှာအသုံးပြုခွင့်သည် ဤအပိုင်းကို ဖွင့်ခွင့်ပေးပြီး ခွင့်ပြုချက်များသည် အတွင်းရှိ ခလုတ်နှင့် လုပ်ဆောင်ချက်များကို အသုံးပြုခွင့်ပေးသည်။", + ["role.nothingInsidePage"] = "ဤစာမျက်နှာအတွင်း ထပ်မံအသုံးပြုနိုင်သောအရာ မရှိပါ။", + ["role.crudPermissions"] = "ဤစာမျက်နှာအတွက် CRUD ခွင့်ပြုချက်များ", + ["role.willBeSaved"] = "သိမ်းဆည်းမည်", + ["role.selectedItems"] = "ရွေးထားသောအရာ {count} ခု", + ["role.accessSaved"] = "အသုံးပြုခွင့်များကို အောင်မြင်စွာ သိမ်းပြီးပါပြီ။", + ["role.failedLoadAccess"] = "အသုံးပြုခွင့် အချက်အလက်များကို ဖတ်၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["role.failedSaveAccess"] = "အသုံးပြုခွင့်ကို သိမ်း၍ မရပါ။", + ["common.unknown"] = "မသိပါ", + ["common.saving"] = "သိမ်းနေသည်...", + ["common.creating"] = "ဖန်တီးနေသည်...", + ["common.searchRoles"] = "အခန်းကဏ္ဍ ရှာရန်", + ["common.searchTasksByTitle"] = "အလုပ်ခေါင်းစဉ်ဖြင့် ရှာပါ...", + ["common.allStatuses"] = "အခြေအနေအားလုံး", + ["common.allPriorities"] = "ဦးစားပေးအားလုံး", + ["common.budgetedHours"] = "ခန့်မှန်းနာရီ", + ["common.budgetedHoursOptional"] = "ခန့်မှန်းနာရီ (မဖြည့်လည်းရသည်)", + + ["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.healthWatch"] = "စောင့်ကြည့်", + ["report.health"] = "ကျန်းမာရေး အခြေအနေ", + ["report.delay"] = "နှောင့်နှေးချက်", + ["report.noEffortEntries"] = "အချိန်မှတ်တမ်း မတွေ့ပါ", + ["report.noEffortEntriesDesc"] = "လက်ရှိစစ်ထုတ်မှုများနှင့် ကိုက်ညီသော အချိန်မှတ်တမ်း မရှိပါ။", + ["report.totalEstimatedEffort"] = "စုစုပေါင်း ခန့်မှန်းအားစိုက်မှု", + ["report.variance"] = "ကွာခြားချက်", + ["report.utilization"] = "အသုံးချမှု", + ["report.timesheetEntries"] = "အချိန်မှတ်တမ်းပြဿနာများ", + ["report.delayType"] = "နှောင့်နှေးမှုအမျိုးအစား", + ["report.days"] = "ရက်", + ["report.aheadOfSchedule"] = "အစီအစဉ်ထက် စောနေသည်", + ["report.blockedOverdue"] = "ပိတ်ဆို့ / နောက်ကျ", + ["report.noProjectData"] = "စစ်ထုတ်ချက်နှင့် ကိုက်ညီသော စီမံကိန်းအချက်အလက် မတွေ့ပါ။", + ["report.employee"] = "ဝန်ထမ်း", + ["report.assignedIssues"] = "တာဝန်ပေးထားသော ပြဿနာများ", + ["report.openOverdue"] = "ဖွင့်ထား / နောက်ကျ", + ["report.workload"] = "အလုပ်ပမာဏ", + ["report.trend"] = "အလားအလာ", + ["report.workloadHigh"] = "များ", + ["report.workloadLight"] = "နည်း", + ["report.workloadBalanced"] = "မျှတ", + ["report.trendImproving"] = "တိုးတက်နေသည်", + ["report.trendDeclining"] = "ကျဆင်းနေသည်", + ["report.trendStable"] = "တည်ငြိမ်", + ["report.noEmployeeData"] = "စစ်ထုတ်ချက်နှင့် ကိုက်ညီသော ဝန်ထမ်းအချက်အလက် မတွေ့ပါ။", + + ["validation.startDateRequired"] = "စတင်ရက် လိုအပ်ပါသည်။", + ["validation.dueDateRequired"] = "ပြီးဆုံးရက် လိုအပ်ပါသည်။", + ["validation.dueDateBeforeStart"] = "ပြီးဆုံးရက်သည် စတင်ရက်ထက် စော၍ မရပါ။", + ["validation.projectNameRequired"] = "စီမံကိန်းအမည် လိုအပ်ပါသည်။", + ["validation.endDateBeforeStart"] = "ပြီးဆုံးရက်သည် စတင်ရက်ထက် စော၍ မရပါ။", + ["validation.taskTitleRequired"] = "လုပ်ငန်းခေါင်းစဉ် လိုအပ်ပါသည်။", + ["validation.selectProjectRequired"] = "စီမံကိန်းတစ်ခုကို ရွေးပါ။", + ["validation.selectTaskRequired"] = "လုပ်ငန်းတစ်ခုကို ရွေးပါ။", + ["validation.issueTitleRequired"] = "ပြဿနာခေါင်းစဉ် လိုအပ်ပါသည်။", + ["validation.completeNameRequired"] = "မြန်မာ သို့မဟုတ် အင်္ဂလိပ်ဘာသာဖြင့် အမည်အပြည့်အစုံ ထည့်ပါ။", + ["validation.roleNameRequired"] = "အခန်းကဏ္ဍအမည် လိုအပ်ပါသည်။", + ["validation.passwordMinLength"] = "စကားဝှက်မှာ အနည်းဆုံး ၆ လုံး ရှိရမည်။", + ["validation.usernameMinLength"] = "အသုံးပြုသူအမည်မှာ အနည်းဆုံး ၃ လုံး ရှိရမည်။", + ["validation.usernameNoSpaces"] = "အသုံးပြုသူအမည်တွင် space မပါရပါ။", + ["validation.usernameInvalidCharacters"] = "အသုံးပြုသူအမည်တွင် space မပါရပါ၊ စာလုံး၊ နံပါတ်၊ underscore (_) နှင့် dot (.) သာ အသုံးပြုနိုင်သည်။", + ["validation.passwordMinLengthRule"] = "စကားဝှက်မှာ အနည်းဆုံး ၈ လုံး ရှိရမည်။", + ["validation.passwordComplexityRule"] = "စကားဝှက်တွင် အနည်းဆုံး uppercase တစ်လုံး၊ lowercase တစ်လုံး၊ နံပါတ်တစ်လုံးနှင့် အထူးလက္ခဏာတစ်လုံး ပါရမည်။", + ["validation.fillAllFields"] = "လိုအပ်သော အကွက်များအားလုံးကို ဖြည့်ပါ။", + + ["common.password"] = "စကားဝှက်", + ["common.usernameOrEmail"] = "အသုံးပြုသူအမည် သို့မဟုတ် ဂျီမေးလ်", + ["common.email"] = "အီးမေးလ်", + ["auth.registerAccountTitle"] = "Taskify အကောင့် ဖန်တီးပါ", + ["auth.alreadyHaveAccess"] = "အကောင့်ရှိပြီးသားလား?", + ["auth.firstName"] = "ပထမအမည်", + ["auth.lastName"] = "မျိုးရိုးအမည်", + ["auth.firstNamePlaceholder"] = "ပထမအမည် ထည့်ပါ", + ["auth.lastNamePlaceholder"] = "မျိုးရိုးအမည် ထည့်ပါ", + ["auth.phone"] = "ဖုန်းနံပါတ် (မဖြစ်မနေ မဟုတ်ပါ)", + ["auth.phonePlaceholder"] = "ဖုန်းနံပါတ် ထည့်ပါ", + ["auth.register"] = "အကောင့်ဖွင့်ရန်", + ["auth.registerTitle"] = "မှတ်ပုံတင်ရန် - Taskify", + ["auth.usernameOrEmail"] = "အသုံးပြုသူအမည် သို့မဟုတ် ဂျီမေးလ်", + ["auth.enterUsernameOrEmail"] = "အသုံးပြုသူအမည် သို့မဟုတ် ဂျီမေးလ် ထည့်ပါ", + ["auth.recoveryCode"] = "ပြန်လည်ရယူရေးကုဒ်", + ["auth.enterRecoveryCode"] = "ပြန်လည်ရယူရေးကုဒ် ထည့်ပါ", + ["auth.newPassword"] = "စကားဝှက်အသစ်", + ["auth.resetPasswordHelp"] = "API တွင် သတ်မှတ်ထားသော ပြန်လည်ရယူရေးကုဒ်ဖြင့် စကားဝှက်အသစ် သတ်မှတ်ပါ။", + ["auth.unableToResetPassword"] = "စကားဝှက်ကို ပြန်လည်သတ်မှတ်၍ မရပါ။", + ["task.loadingDetails"] = "လုပ်ငန်း အသေးစိတ်များကို ဖွင့်နေသည်...", + ["task.notFound"] = "လုပ်ငန်း ရှာမတွေ့ပါ", + ["task.notFoundDesc"] = "သင်ရှာဖွေနေသော လုပ်ငန်းသည် မရှိတော့ပါ သို့မဟုတ် ဖျက်လိုက်ပါပြီ။", + ["task.editTitle"] = "ခေါင်းစဉ် ပြင်ဆင်ရန်", + ["task.saving"] = "သိမ်းဆည်းနေသည်...", + ["task.properties"] = "ဂုဏ်သတ္တိများ", + ["comment.comments"] = "မှတ်ချက်များ", + ["comment.deleteComment"] = "မှတ်ချက်ဖျက်မည်", + ["comment.deleteCommentTitle"] = "မှတ်ချက် ဖျက်မလား?", + ["comment.deleteCommentConfirm"] = "ဤမှတ်ချက်ကို ဖျက်ရန် သေချာပါသလား။ ဤလုပ်ဆောင်ချက်ကို ပြန်ပြင်၍မရပါ။", + ["comment.noComments"] = "မှတ်ချက်များ မရှိသေးပါ။", + ["comment.startConversation"] = "အောက်တွင် စကားဝိုင်းကို စတင်ပါ။", + ["comment.writeComment"] = "မှတ်ချက်တစ်ခု ရေးပါ...", + ["theme.switchToDarkMode"] = "Dark mode သို့ ပြောင်းရန်", + ["theme.switchToLightMode"] = "Light mode သို့ ပြောင်းရန်", + ["page.loadingPage"] = "စာမျက်နှာကို ဖွင့်နေသည်...", + ["page.notFound"] = "စာမျက်နှာ ရှာမတွေ့ပါ။", + ["report.projectHealthMatrix"] = "စီမံကိန်း ကျန်းမာရေး မက်ထရစ်", + ["report.projectHealthMatrixDesc"] = "ပြဿနာ အန္တရာယ်နှင့် စီမံကိန်းတိုးတက်မှု အခြေအနေ။", + ["report.projectHealthMatrixInsight"] = "အပေါ်ညာဘက်ရှိ စီမံကိန်းများသည် တိုးတက်မှုနှေးကွေးပြီး ပြဿနာဖိအားများ ရှိနေသည်။", + ["report.actualEffortAboveEstimate"] = "အမှန်တကယ် လုပ်ဆောင်မှုမှာ ခန့်မှန်းခြေထက် {hours} နာရီ ပိုများနေသည်။", + ["report.actualEffortUnderEstimate"] = "အမှန်တကယ် လုပ်ဆောင်မှုမှာ ခန့်မှန်းခြေအောက် {hours} နာရီ လျော့နည်းသည်။", + ["report.utilizationAbovePlan"] = "အသုံးပြုမှုသည် အစီအစဉ်ထက် ကျော်လွန်နေသည်။ ခန့်မှန်းချက်များနှင့် ပိတ်ဆို့မှုများကို ပြန်လည်သုံးသပ်ပါ။", + ["report.utilizationBelowPlan"] = "လုပ်ဆောင်မှုမှတ်တမ်းမှာ အစီအစဉ်အောက် ရောက်နေသည်။ အလုပ်မှတ်တမ်းများ သေချာတင်ရန် လိုအပ်သည်။", + ["report.utilizationCloseToPlan"] = "အသုံးပြုမှုသည် စီမံကိန်း ရေးဆွဲမှု အကွာအဝေးအတွင်း ရှိသည်။", + ["report.accuracyExceedingEstimate"] = "ပြဿနာအများစုမှာ ခန့်မှန်းခြေထက် ကျော်လွန်နေသည်။", + ["report.accuracyWithinEstimate"] = "ပြဿနာအများစုမှာ ခန့်မှန်းခြေအတွင်း သို့မဟုတ် အောက်တွင် ရှိသည်။", + ["report.remainingEstimate"] = "ကျန်ရှိသော ခန့်မှန်းခြေ", + ["report.overLabel"] = "ကျော်လွန်", + ["report.overEstimateDesc"] = "ခန့်မှန်းချက်ထက် ကျော်လွန်သည်", + ["report.legend"] = "ရှင်းလင်းချက်", + ["report.riskSeverity"] = "အန္တရာယ် ပြင်းထန်မှု", + ["report.lowUrgency"] = "အရေးတကြီးဖြစ်မှု နည်း", + ["report.highUrgency"] = "အရေးတကြီးဖြစ်မှု များ", + ["report.lowRisk"] = "အန္တရာယ် နည်း", + ["report.noMatrixData"] = "မက်ထရစ် အချက်အလက် မတွေ့ပါ။", + ["report.riskMatrix"] = "အန္တရာယ် မက်ထရစ်", + ["report.noProjects"] = "စီမံကိန်း မရှိပါ", + ["report.urgencyLabel"] = "အရေးတကြီးဖြစ်မှု", + ["report.projectsCount"] = "စီမံကိန်း(များ)", + ["report.itemsCount"] = "ခု", + ["report.noComparisonData"] = "နှိုင်းယှဉ်ရန် အချက်အလက် မရှိပါ။", + ["report.medium"] = "အလယ်အလတ်", + ["report.completionVsRisk"] = "ပြီးစီးမှု နှင့် အန္တရာယ်", + ["report.completionVsRiskInsight"] = "ပြဿနာဖိအားများပြီး ပြီးစီးမှုနည်းသော စီမံကိန်းများကို ဦးစွာ ပြန်လည်သုံးသပ်သင့်သည်။", + ["report.reviewNow"] = "ချက်ချင်း သုံးသပ်ရန်", + ["report.lowCompletionHighRisk"] = "ပြီးစီးမှုနည်း · အန္တရာယ်များ", + ["report.watchClosely"] = "အနီးကပ် စောင့်ကြည့်ရန်", + ["report.highCompletionHighRisk"] = "ပြီးစီးမှုများ · အန္တရာယ်များ", + ["report.recoverPlan"] = "အစီအစဉ် ပြန်လည်ထူထောင်ရန်", + ["report.lowCompletionLowRisk"] = "ပြီးစီးမှုနည်း · အန္တရာယ်နည်း", + ["report.onTrack"] = "ပုံမှန်အတိုင်းရှိသည်", + ["report.highCompletionLowRisk"] = "ပြီးစီးမှုများ · အန္တရာယ်နည်း", + ["report.percentComplete"] = "ပြီးစီးမှု {percent}%", + ["report.color"] = "အရောင်", + ["report.topAtRiskProjects"] = "အန္တရာယ်အများဆုံး စီမံကိန်းများ", + ["report.topAtRiskProjectsDesc"] = "စီမံကိန်းရေးဆွဲရန် ဦးစွာ ဂရုစိုက်ရမည့် စီမံကိန်းများ။", + ["report.topAtRiskProjectsInsight"] = "အန္တရာယ်များတွင် ရက်လွန်၊ ပိတ်ဆို့မှု၊ ဖွင့်ထားသော ပြဿနာများနှင့် စီမံကိန်း ကျန်းမာရေးတို့ ပါဝင်သည်။", + ["report.timelineSummary"] = "အချိန်ဇယား အနှစ်ချုပ်", + ["report.timelineSummaryDesc"] = "ပြီးစီးမှုအခြေအနေပါရှိသော စီမံကိန်း အချိန်ဇယား။", + ["report.timelineSummaryInsight"] = "ဤအရာသည် အစီရင်ခံစာမှ ထွက်စရာမလိုဘဲ စီမံကိန်းရေးဆွဲမှု အခြေအနေကို ဖော်ပြပေးသည်။", + ["report.openOverdueBlockingIssues"] = "စီမံကိန်းအလိုက် ဖွင့်ထားသော၊ ရက်လွန်နှင့် ပိတ်ဆို့ထားသော ပြဿနာများ", + ["report.issuesCompositionByProject"] = "စီမံကိန်းအလိုက် ဖွင့်ထားသော၊ ရက်လွန်နှင့် ပိတ်ဆို့ထားသော ပြဿနာများ", + ["report.issuesCompositionByProjectDesc"] = "စီမံကိန်းစုစုပေါင်းရှိ ပြဿနာများ၏ ဖွဲ့စည်းမှု။", + ["report.issuesCompositionByProjectInsight"] = "လုပ်ဆောင်ဆဲအလုပ်သည် ပုံမှန်ဖြစ်သော်လည်း ရက်လွန်နှင့် ပိတ်ဆို့ထားသောအလုပ်များသည် ဖြေရှင်းရန် အချက်ပြချက်ဖြစ်သည်။", + ["report.noAtRiskProjects"] = "လက်ရှိစစ်ထုတ်မှုတွင် အန္တရာယ်ရှိသော စီမံကိန်းများ မရှိပါ။", + ["report.estVsActualEffort"] = "ခန့်မှန်းခြေနှင့် အမှန်တကယ် လုပ်ဆောင်မှု", + ["report.estVsActualEffortDesc"] = "အစီအစဉ်များသည် အမှန်တကယ် လုပ်ဆောင်မှုနှင့် ကိုက်ညီပါသလား။", + ["report.actual"] = "အမှန်တကယ်", + ["report.estimated"] = "ခန့်မှန်းခြေ", + ["report.utilizationGaugeDesc"] = "ခန့်မှန်းခြေအပေါ် အမှန်တကယ် အသုံးပြုထားသော နာရီများ၏ အချိုးအစား။", + ["report.varianceLabel"] = "ကွာခြားချက်", + ["report.usedLabel"] = "အသုံးပြုမှု", + ["report.estimateAccuracyDesc"] = "ခန့်မှန်းခြေအတွင်း မည်မျှပြီးစီးခဲ့သနည်း။", + ["report.onOrUnder"] = "ခန့်မှန်းချက်အတွင်း/အောက်", + ["report.issuesLabel"] = "ပြဿနာများ", + ["report.effortByProjectDesc"] = "လှုပ်ရှားနေသော စီမံကိန်းများအကြား အမှန်တကယ် နာရီများ ဖြန့်ဝေမှု။", + ["report.effortByProjectInsight"] = "မျှော်လင့်ထားသည်ထက် ပိုမိုလုပ်ဆောင်မှုများ သုံးစွဲနေသော စီမံကိန်းများကို ရှာဖွေရန် ဤအရာကို သုံးပါ။", + ["report.effortLeaderboardDesc"] = "အလုပ်မှတ်တမ်း အများဆုံးရှိသော ဝန်ထမ်းများ။", + ["report.effortLeaderboardInsight"] = "အမည်များသည် လုပ်ဆောင်နိုင်စွမ်း ခွဲဝေမှုကို ဖော်ပြခြင်းဖြစ်ပြီး စွမ်းဆောင်ရည်ကို အကဲဖြတ်ခြင်း မဟုတ်ပါ။", + ["report.noEmployeeEffort"] = "ဝန်ထမ်းများ၏ လုပ်ဆောင်မှု အချက်အလက် မရှိပါ။", + ["report.openIssuesSegment"] = "ဖွင့်ထားသော ပြဿနာများ", + ["report.overdueIssuesSegment"] = "ရက်လွန် ပြဿနာများ", + ["report.blockedIssuesSegment"] = "ပိတ်ဆို့ထားသော ပြဿနာများ", + ["report.keyInsights"] = "အဓိက သုံးသပ်ချက်များ", + ["project.assignDescription"] = "လုပ်ငန်းမြင်နိုင်မှုကို ထိန်းချုပ်ရန် အဖွဲ့ဝင်များကို စီမံကိန်းများသို့ သတ်မှတ်ပေးပါ။", + ["project.assignmentsSaved"] = "စီမံကိန်း တာဝန်ချထားမှုများကို အောင်မြင်စွာ သိမ်းဆည်းပြီးပါပြီ။", + ["project.failedSaveAssignment"] = "တာဝန်ချထားမှုကို သိမ်းဆည်း၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["project.selectProjectPrompt"] = "အသုံးပြုသူ ခွဲဝေမှုများကို စီမံခန့်ခွဲရန် ဘယ်ဘက်အကန့်မှ စီမံကိန်းတစ်ခုကို ရွေးချယ်ပေးပါ။", + ["issue.addIssueDescription"] = "လုပ်ငန်းတစ်ခုအတွက် တစ်ရက်တာ အလုပ်ပြဿနာတစ်ခု ဖန်တီးပြီး သင်အသုံးပြုခဲ့သည့် နာရီများကို မှတ်တမ်းတင်ပါ။", + ["issue.untitledIssue"] = "ခေါင်းစဉ်မရှိသော ပြဿနာ", + ["issue.created"] = "ပြဿနာကို အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ။", + ["issue.updated"] = "ပြဿနာကို အောင်မြင်စွာ ပြင်ဆင်ပြီးပါပြီ။", + ["issue.deleted"] = "ပြဿနာကို အောင်မြင်စွာ ဖျက်ပြီးပါပြီ။", + ["issue.saveConfirmMessage"] = "ဤပြဿနာ၏ ပြင်ဆင်ချက်များကို သိမ်းဆည်းရန် သေချာပါသလား။", + ["issue.whatDidYouWorkOn"] = "သင် ဘာလုပ်ခဲ့သလဲ။", + ["issue.addShortNote"] = "သင်လုပ်ဆောင်ခဲ့သည့် အလုပ်အကြောင်း အကျဉ်းချုပ် ရေးသားပါ...", + ["issue.exampleEstimatedHours"] = "ဥပမာ ၄", + ["issue.exampleActualHours"] = "ဥပမာ ၇", + ["issue.actualHoursHint"] = "ယနေ့ သင်အသုံးပြုခဲ့သည့် စုစုပေါင်းနာရီကို ထည့်သွင်းပါ။ ပုံမှန်အလုပ်လုပ်ရက်အတွက် ဤအရာသည် ပုံမှန်အားဖြင့် ၇ နာရီ ဖြစ်သည်။", + ["issue.blockedByPlaceholder"] = "မှီခိုမှု၊ လူ သို့မဟုတ် အဖွဲ့", + ["issue.delayReasonPlaceholder"] = "ဤပြဿနာ အဘယ်ကြောင့် နှောင့်နှေးရသနည်း။", + ["issue.teamLead"] = "အဖွဲ့ခေါင်းဆောင်", + ["issue.pm"] = "စီမံကိန်းမန်နေဂျာ", + ["issue.manager"] = "မန်နေဂျာ", + ["translation.generateEnglishSpelling"] = "အင်္ဂလိပ်စာလုံးပေါင်း ဖန်တီးပေးရန်", + ["translation.generateBurmeseName"] = "မြန်မာအမည် ရေးသားပေးရန်", + ["translation.generateEnglish"] = "အင်္ဂလိပ်ဘာသာ ပြန်ဆိုရန်", + ["translation.generateBurmese"] = "မြန်မာဘာသာ ပြန်ဆိုရန်", + ["translation.englishDetected"] = "ဤစာသားသည် အင်္ဂလိပ်ဘာသာဖြစ်ပုံရသည်။ အင်္ဂလိပ်အကွက်သို့ ရွှေ့မည်လား။", + ["issue.failedCreate"] = "ပြဿနာ ဖန်တီး၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["issue.dateRangeInvalid"] = "ပြီးဆုံးရက်သည် စတင်ရက်ထက် စော၍ မရပါ။", + ["user.editUser"] = "အသုံးပြုသူကို ပြင်ဆင်မည်", + ["user.createUser"] = "အသုံးပြုသူ ဖန်တီးမည်လား။", + ["user.updateUser"] = "အသုံးပြုသူကို အပ်ဒိတ်လုပ်မည်လား။", + ["user.deleteUser"] = "အသုံးပြုသူကို ဖျက်မည်လား။", + ["user.updateUserConfirm"] = "ဤအသုံးပြုသူကို အပ်ဒိတ်လုပ်ရန် သေချာပါသလား။", + ["user.createUserConfirm"] = "ဤအသုံးပြုသူကို ဖန်တီးရန် သေချာပါသလား။", + ["user.deleteUserConfirm"] = "ဤအသုံးပြုသူကို ဖျက်ရန် သေချာပါသလား။ ဤလုပ်ဆောင်ချက်ကို ပြန်ပြင်၍ မရပါ။", + ["user.cannotDeleteSelf"] = "မိမိ၏အကောင့်ကို မိမိကိုယ်တိုင် ဖျက်၍ မရပါ။", + ["result.userCreated"] = "အသုံးပြုသူကို အောင်မြင်စွာ ဖန်တီးပြီးပါပြီ။", + ["result.userUpdated"] = "အသုံးပြုသူကို အောင်မြင်စွာ အပ်ဒိတ်လုပ်ပြီးပါပြီ။", + ["result.userDeleted"] = "အသုံးပြုသူကို အောင်မြင်စွာ ဖျက်ပြီးပါပြီ။", + ["result.failedToDeleteUser"] = "အသုံးပြုသူကို ဖျက်၍ မရပါ။", + ["result.userSaveFailed"] = "အသုံးပြုသူကို သိမ်းဆည်း၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["status.blocked"] = "ပိတ်ဆို့ထားသည်", + ["report.assignedToMe"] = "ကျွန်ုပ်ထံ တာဝန်ပေးထားသော", + ["report.assignedToMyTeam"] = "ကျွန်ုပ်၏အဖွဲ့ထံ တာဝန်ပေးထားသော", + ["report.total"] = "စုစုပေါင်း", + ["report.capacitySupportList"] = "လုပ်ဆောင်နိုင်စွမ်း အကူအညီစာရင်း", + ["report.completedVsOpenIssues"] = "ပြီးစီးသော နှင့် ဖွင့်ထားသော ပြဿနာများ", + ["report.completionPattern"] = "ပြီးစီးမှု ပုံစံ", + ["report.effortByProject"] = "စီမံကိန်းအလိုက် လုပ်ဆောင်မှု", + ["report.effortLeaderboard"] = "အလုပ်အများဆုံး အဖွဲ့ဝင်များ", + ["report.estimateAccuracy"] = "ခန့်မှန်းမှု တိကျမှု", + ["report.estimatedVsActual"] = "ခန့်မှန်းခြေနှင့် အမှန်တကယ် လုပ်ဆောင်မှု", + ["report.overdueSupport"] = "ရက်လွန်မှု အကူအညီ", + ["report.utilizationGauge"] = "အသုံးပြုမှု တိုင်းတာမှု", + ["report.workloadHeatmap"] = "အလုပ်ပမာဏ အပူမြေပုံ", + ["common.rememberMe"] = "အကောင့်ဝင်ထားရန် မှတ်မည်", + ["auth.needAccount"] = "အကောင့်မရှိသေးပါသလား?", + ["auth.resetPassword"] = "စကားဝှက် ပြန်လည်သတ်မှတ်ရန်", + ["result.signedOut"] = "အကောင့်မှ အောင်မြင်စွာ ထွက်ပြီးပါပြီ။", + ["result.passwordUpdated"] = "စကားဝှက်ကို အောင်မြင်စွာ ပြောင်းပြီးပါပြီ။ စကားဝှက်အသစ်ဖြင့် အကောင့်ဝင်ပါ။", + ["result.apiUnavailable"] = "API ဆာဗာကို ဆက်သွယ်၍ မရပါ။ TaskTrackingSystem.WebApi ကို ဖွင့်ထားပြီး ပြန်ကြိုးစားပါ။", + ["result.registrationFailed"] = "အကောင့်ဖွင့်၍ မရပါ။ ထပ်မံကြိုးစားပါ။", + ["result.resetPasswordFailed"] = "စကားဝှက်ကို ပြန်လည်သတ်မှတ်၍ မရပါ။", + ["result.invalidCredentials"] = "အသုံးပြုသူအမည်/ဂျီမေးလ် သို့မဟုတ် စကားဝှက် မမှန်ပါ။", + ["result.noPermissionUpdateTasks"] = "လုပ်ငန်းများကို ပြင်ဆင်ရန် ခွင့်ပြုချက် မရှိပါ။", + ["result.usernameTaken"] = "ဤအသုံးပြုသူအမည်ကို အသုံးပြုပြီးသား ဖြစ်ပါသည်။", + ["result.emailRegistered"] = "ဤအီးမေးလ်ကို မှတ်ပုံတင်ပြီးသား ဖြစ်ပါသည်။", + ["result.noSystemRoles"] = "စနစ်အခန်းကဏ္ဍများ မသတ်မှတ်ထားသေးပါ။ အကောင့်ဖွင့်၍ မရပါ။", + ["result.invalidRecoveryCode"] = "ပြန်လည်ရယူရေးကုဒ် မမှန်ပါ။", + ["result.accountNotFound"] = "အကောင့် ရှာမတွေ့ပါ။", + ["report.1to3Days"] = "၁-၃ ရက်", + ["report.4to7Days"] = "၄-၇ ရက်", + ["report.8PlusDays"] = "၈ ရက်နှင့်အထက်", + ["report.1to7Late"] = "၁-၇ ရက် နောက်ကျ", + ["report.8PlusLate"] = "၈ ရက်နှင့်အထက် နောက်ကျ", + ["report.agingBuckets"] = "ကြာမြင့်ချိန် အပိုင်းများ", + ["report.agingBucketsDesc"] = "နှောင့်နှေးနေသော အလုပ်များ စောင့်ဆိုင်းနေရသည့် ကြာချိန်။", + ["report.allReportedIssues"] = "အစီရင်ခံထားသော ပြဿနာအားလုံး", + ["report.avgRate"] = "ပျမ်းမျှနှုန်း", + ["report.assignedColumn"] = "တာဝန်ပေးထားသော", + ["report.openColumn"] = "ဖွင့်ထားသော", + ["report.overdueColumn"] = "ရက်လွန်", + ["report.capacitySupportDesc"] = "အကူအညီလိုအပ်မှု အချက်ပြချက်များကို လုပ်ဆောင်နိုင်စွမ်း အန္တရာယ်အဖြစ် ဖော်ပြထားသည်။", + ["report.capacitySupportInsight"] = "အကူအညီ ခွဲဝေပေးရန်နှင့် ပိတ်ဆို့မှုများ ဖယ်ရှားရန် အသုံးပြုပါ၊ လူများကို အဆင့်သတ်မှတ်ရန် မဟုတ်ပါ။", + ["report.completedIssues"] = "ပြီးစီးသော ပြဿနာများ", + ["report.completedWorkLeading"] = "လက်ရှိ စစ်ထုတ်ထားသော အစုတွင် ပြီးစီးသောအလုပ်က အများဆုံးဖြစ်သည်။", + ["report.completion"] = "ပြီးစီးမှု", + ["report.completionHealthy"] = "လက်ရှိ စစ်ထုတ်မှုအရ ပြီးစီးမှု အခြေအနေကောင်းမွန်သည်။", + ["report.completionImprove"] = "ပြီးစီးမှု တိုးတက်ရန် နေရာရှိသည်။ ဖွင့်ထားသော ပြဿနာများနှင့် ရက်လွန်အလုပ်များကို စစ်ဆေးပါ။", + ["report.completionMovement"] = "ပြီးစီးမှု ရွေ့လျားမှု", + ["report.completionMovementDesc"] = "ဖန်တီးသည့်လအလိုက် မကြာသေးမီက ပြီးစီးသော လုပ်ငန်းများ။", + ["report.completionPatternDesc"] = "မြင်ရသော ဝန်ထမ်းများ၏ ပြီးစီးမှုနှုန်း။", + ["report.completionPatternInsight"] = "ပြီးစီးမှုသည် တစ်နေရာတည်းတွင် စုနေသလား သို့မဟုတ် မျှတသလားကို အမြန်ကြည့်နိုင်သည်။", + ["report.completionVsRiskDesc"] = "စီမံကိန်းတစ်ခုချင်းစီကို ပြီးစီးမှုနှင့် ပြဿနာဖိအားအလိုက် ပြထားသည်။", + ["report.couldNotLoad"] = "လုပ်ငန်း အစီရင်ခံစာကို ဖွင့်၍ မရပါ။", + ["report.created"] = "ဖန်တီးထားသော", + ["report.critical"] = "အရေးကြီး", + ["report.criticalTasksNeedAttention"] = "အရေးကြီးသော လုပ်ငန်းများကို ဂရုပြုရန် လိုအပ်သည်။", + ["report.currentlyBeingWorked"] = "လက်ရှိ လုပ်ဆောင်နေသည်", + ["report.delayReason"] = "နှောင့်နှေးရသည့် အကြောင်းပြချက်", + ["report.dueLater"] = "နောက်ပိုင်း ပြီးစီးရန်", + ["report.dueSoon"] = "မကြာမီ ပြီးစီးရန်", + ["report.employeesWithOverdueLoad"] = "ရက်လွန် ပြဿနာအလုပ်ပမာဏရှိသော ဝန်ထမ်းများ။", + ["report.executionRisk"] = "စစ်ထုတ်ထားသော ပြဿနာအစု၏ လုပ်ဆောင်မှု အန္တရာယ်။", + ["report.focusStandup"] = "နေ့စဉ်အစည်းအဝေးတွင် ဤအချက်ကို ဦးစွာ ဆွေးနွေးပါ။", + ["report.highPriorityTasks"] = "ဦးစားပေးမြင့် လုပ်ငန်းများ", + ["report.highPriorityVisible"] = "စီမံကိန်းပြန်လည်သုံးသပ်ချိန်တွင် ဦးစားပေးမြင့် လုပ်ငန်းများကို ဆက်လက်မြင်ရအောင်ထားသင့်သည်။", + ["report.issueLifecycleFunnel"] = "ပြဿနာ လုပ်ဆောင်မှုအဆင့်များ", + ["report.issueLifecycleFunnelDesc"] = "ပြဿနာများသည် backlog မှ ဖြေရှင်းပြီးသည်အထိ မည်သို့ရွေ့လျားသနည်း။", + ["report.issueLifecycleInsight"] = "ဆောင်ရွက်နေသော သို့မဟုတ် ပိတ်ဆို့ထားသော အပိုင်းများများပြားခြင်းသည် နေ့စဉ်လုပ်ဆောင်မှု အချက်ပြချက်ဖြစ်သည်။", + ["report.issuesByAssignee"] = "တာဝန်ယူသူအလိုက် ပြဿနာများ", + ["report.issuesByAssigneeDesc"] = "တာဝန်ယူသူအလိုက် လိုအပ်သော အကူအညီ။", + ["report.issuesByAssigneeInsight"] = "အထူးသဖြင့် ရက်လွန် သို့မဟုတ် ပိတ်ဆို့ထားသည့် အရာများ စုနေချိန်တွင် အကူအညီလိုအပ်မှု အချက်ပြချက်အဖြစ် အသုံးပြုပါ။", + ["report.issuesByProject"] = "စီမံကိန်းအလိုက် ပြဿနာများ", + ["report.issuesByProjectDesc"] = "ပြဿနာအရေအတွက် မည်သည့်နေရာတွင် စုစည်းနေသနည်း။", + ["report.issuesByProjectInsight"] = "စီမံကိန်းအဆင့် ပြဿနာခွဲခြမ်းခြင်းနှင့် စီမံကိန်းကျန်းမာရေးအတွက် အသုံးဝင်သည်။", + ["report.issuesStillOpen"] = "ရွေးချယ်ထားသော အတိုင်းအတာအတွင်း ပြဿနာများ ဖွင့်ထားဆဲဖြစ်သည်။", + ["report.issueStatusDonut"] = "ပြဿနာ အခြေအနေ စက်ဝိုင်းဇယား", + ["report.issueStatusDonutDesc"] = "ရက်လွန်ဖိအားအပါအဝင် လက်ရှိအခြေအနေများ၏ ဖြန့်ဝေမှု။", + ["report.loadError"] = "လုပ်ငန်း အစီရင်ခံစာဒေတာ ဖွင့်နေစဉ် ဆာဗာအမှားတစ်ခု ပြန်ပေးခဲ့သည်။", + ["report.loadingIssueReport"] = "ပြဿနာ အစီရင်ခံစာကို ဖွင့်နေသည်...", + ["report.loadingOverdueTasks"] = "ရက်လွန်လုပ်ငန်းများကို ဖွင့်နေသည်...", + ["report.loadingTimesheet"] = "အချိန်မှတ်တမ်းဒေတာကို ဖွင့်နေသည်...", + ["report.manager"] = "မန်နေဂျာ", + ["report.needsUnblockReview"] = "ပိတ်ဆို့မှု ဖြေရှင်းရန် ပြန်လည်သုံးသပ်ရမည်", + ["report.noAssigneeDelay"] = "တာဝန်ယူသူအလိုက် နှောင့်နှေးမှု စုစည်းနေခြင်း မတွေ့ပါ။", + ["report.noCapacityRisks"] = "လက်ရှိ စစ်ထုတ်မှုတွင် လုပ်ဆောင်နိုင်စွမ်း အန္တရာယ် မရှိပါ။", + ["report.noHighPriority"] = "လက်ရှိ စစ်ထုတ်မှုတွင် ဦးစားပေးမြင့် လုပ်ငန်း မရှိပါ။", + ["report.noOverdueSupport"] = "ရက်လွန်မှုနှင့် ပတ်သက်သော အကူအညီလိုအပ်ချက် မတွေ့ပါ။", + ["report.noProjectDelay"] = "စီမံကိန်းအလိုက် နှောင့်နှေးမှု စုစည်းနေခြင်း မတွေ့ပါ။", + ["report.openIssuesAttached"] = "အစီရင်ခံထားသော လုပ်ငန်းများနှင့် ဖွင့်ထားသော ပြဿနာများ ချိတ်ဆက်ထားသည်။", + ["report.overdueSupportInsight"] = "ရက်လွန်အရေအတွက်များကို အကူအညီတောင်းဆိုမှုများအဖြစ် သတ်မှတ်ပါ။", + ["report.overdueTasks"] = "ရက်လွန် လုပ်ငန်းများ", + ["report.pm"] = "စီမံကိန်းမန်နေဂျာ", + ["report.priorityDistribution"] = "ဦးစားပေးအလိုက် ဖြန့်ဝေမှု", + ["report.priorityDistributionDesc"] = "လုပ်ငန်းဦးစားပေးအလိုက် စီမံကိန်းဖိအား။", + ["report.resolutionAging"] = "ဖြေရှင်းမှု ကြာမြင့်ချိန်", + ["report.resolutionAgingDesc"] = "ပြဿနာများသည် သတ်မှတ်ရက်နီးကပ်နေသလား သို့မဟုတ် ကျော်လွန်နေသလား။", + ["report.resolutionAgingInsight"] = "နယ်ပယ်အသစ် မတိုးချဲ့မီ ကြာမြင့်နေသော ရက်လွန်အပိုင်းများကို ရှင်းလင်းသင့်သည်။", + ["report.resolvedIssuesLeading"] = "လက်ရှိ စစ်ထုတ်ထားသော အစုတွင် ဖြေရှင်းပြီးသော ပြဿနာများက အများဆုံးဖြစ်သည်။", + ["report.risk"] = "အန္တရာယ်", + ["report.riskScore"] = "အန္တရာယ်အမှတ်", + ["report.riskIssues"] = "အန္တရာယ်ရှိ ပြဿနာများ", + ["report.supportSignal"] = "အကူအညီလိုအပ်မှု အချက်ပြချက်", + ["report.tasks"] = "လုပ်ငန်းများ", + ["report.tasksHaveOverdueIssues"] = "လုပ်ငန်းများတွင် ရက်လွန် ပြဿနာများ ရှိသည်။", + ["report.tasksStillMoving"] = "လုပ်ငန်းများသည် လုပ်ဆောင်မှုအဆင့်များအတွင်း ဆက်လက်ရွေ့လျားနေသည်။", + ["report.taskStatusMix"] = "လုပ်ငန်း အခြေအနေ ရောနှောမှု", + ["report.taskStatusMixDesc"] = "လုပ်ငန်းလုပ်ဆောင်မှုသည် lifecycle အတွင်း မည်သည့်နေရာတွင် ရှိနေသနည်း။", + ["report.tasksWithMostIssues"] = "ဖွင့်ထားသော ပြဿနာအများဆုံး လုပ်ငန်းများ", + ["report.tasksWithMostIssuesDesc"] = "တိုးတက်မှု ဆက်လက်ရွေ့လျားရန် ပြဿနာများ ရှင်းလင်းရန် လိုအပ်နိုင်သော ပိတ်ဆို့မှုများ။", + ["report.tasksWithMostIssuesInsight"] = "မည်သည့်နေရာတွင် ပြဿနာခွဲခြမ်းခြင်းက လုပ်ငန်းတိုးတက်မှုကို အမြန်ဆုံး ဖြစ်စေမည်ကို ဆုံးဖြတ်ရန် အသုံးပြုပါ။", + ["report.teamExecutionBalance"] = "ပြဿနာအခြေအနေအလိုက် အဖွဲ့၏ လုပ်ဆောင်မှု မျှတမှု။", + ["report.teamLead"] = "အဖွဲ့ခေါင်းဆောင်", + ["report.topProjectRisk"] = "အန္တရာယ်အများဆုံး စီမံကိန်း", + ["report.topProjectRiskDesc"] = "လုပ်ငန်းနှင့် ပြဿနာဖိအား အမြင့်ဆုံးရှိသော စီမံကိန်းများ။", + ["report.topProjectRiskInsight"] = "အမှတ်တွင် လုပ်ငန်းအရေအတွက်၊ ဖွင့်ထားသော ပြဿနာ၊ ရက်လွန်ပြဿနာနှင့် အရေးကြီးလုပ်ငန်း အခြေအနေတို့ ပါဝင်သည်။", + ["report.unresolvedIssues"] = "မဖြေရှင်းရသေးသော ပြဿနာများ", + ["report.workloadHeatmapDesc"] = "တာဝန်ပေးထားသော အလုပ်၊ ဖွင့်ထားသော ပြဿနာ၊ ရက်လွန်ပြဿနာနှင့် နာရီများမှ လုပ်ဆောင်နိုင်စွမ်း အချက်ပြချက်များ။", + ["report.workloadHeatmapInsight"] = "အရောင်ပိုမိုနက်သော အကွက်များသည် အကူအညီ သို့မဟုတ် နယ်ပယ်ပြင်ဆင်မှု လိုအပ်နိုင်သည်ကို ဆိုလိုသည်။", + ["report.funnelBaselineNote"] = "အကျယ်ကို {label} ({value}) နှင့် နှိုင်းယှဉ်တိုင်းတာသည်။ တန်ဖိုးသုညအဆင့်များတွင် အရောင်မပါသော လမ်းကြောင်းအလွတ်ကို ထားရှိသည်။", + ["report.noStage"] = "{stage} မရှိပါ", + ["report.funnelTooltip"] = "{label}: {value} (အခြေခံတန်ဖိုး၏ {percent}%)", + ["report.noAgingData"] = "ကြာမြင့်ချိန် အချက်အလက် မရှိပါ။", + ["report.noChartData"] = "အချက်အလက် မရှိပါ။", + ["report.noFunnelData"] = "funnel အချက်အလက် မရှိပါ။", + ["report.noWorkloadData"] = "အလုပ်ပမာဏ အချက်အလက် မရှိပါ။", + ["report.noRiskItems"] = "ပြသရန် အန္တရာယ်အချက်များ မရှိပါ။", + ["report.notEnoughTrendData"] = "အလားအလာပြရန် အချက်အလက် မလုံလောက်သေးပါ။", + ["report.stackedProgress"] = "အဆင့်လိုက် တိုးတက်မှု", + ["report.statusDistribution"] = "အခြေအနေ ဖြန့်ဝေမှု", + ["report.needsSupport"] = "အကူအညီ လိုအပ်သည်", + ["report.tracked"] = "ခြေရာခံပြီး", + ["report.completedTasks"] = "ပြီးစီးသော လုပ်ငန်းများ", + ["report.assignedTasks"] = "တာဝန်ပေးအပ်ထားသော လုပ်ငန်းများ", + ["report.capacityRisk"] = "လုပ်ဆောင်နိုင်စွမ်း အန္တရာယ်", + ["report.noIssues"] = "ပြဿနာ မရှိပါ", + ["report.open"] = "ဖွင့်ထားသော", + ["report.timeTrackingTitle"] = "အချိန်နှင့် အားထုတ်မှု ခြေရာခံခြင်း အစီရင်ခံစာ", + ["report.timeTrackingHeading"] = "အချိန်နှင့် အားထုတ်မှု ခြေရာခံခြင်း", + ["report.timeTrackingDesc"] = "ခန့်မှန်းခြေနာရီများ၊ ပြီးစီးမှု အချိန်ဇယားများနှင့် ပြဿနာများတစ်လျှောက် အရင်းအမြစ် အားထုတ်မှု ခွဲဝေမှုများကို ပြန်လည်သုံးသပ်ပါ။", + ["report.overdueTasksPageTitle"] = "နောက်ကျနှင့် နှောင့်နှေးသော လုပ်ငန်းများ အစီရင်ခံစာ", + ["report.overdueTasksHeading"] = "နောက်ကျနှင့် နှောင့်နှေးသော လုပ်ငန်းများ", + ["report.overdueTasksDesc"] = "သတ်မှတ်ရက် ကျော်လွန်သွားသော သို့မဟုတ် နှောင့်နှေးရန် အန္တရာယ်ရှိသော လုပ်ငန်းများကို ခြေရာခံပါ။", + ["report.highPriorityOverdue"] = "ဦးစားပေးမြင့် ရက်လွန်လုပ်ငန်းများ", + ["report.highPriorityOverdueDesc"] = "ချက်ချင်းပံ့ပိုးမှု လိုအပ်သော အရေးကြီးသည့်အလုပ်များ။", + ["report.overdueInsight"] = "ဤအရာများကို အဖွဲ့များအလုပ်ပိတ်ဆို့မှု ရှင်းလင်းနိုင်ရန် လုပ်ဆောင်နိုင်စွမ်းအန္တရာယ်များအဖြစ် သတ်မှတ်ပါ၊ ပြစ်တင်ရန်မဟုတ်ပါ။", + ["report.dueWithin3Days"] = "၃ ရက်အတွင်း ပြီးစီးရန်", + ["report.avgDaysOverdue"] = "ပျှမ်းမျှ ရက်လွန်နေသည့် ရက်များ", + ["report.allDelayed"] = "နှောင့်နှေးမှု အားလုံး", + ["report.overduePastDue"] = "ရက်လွန် (သတ်မှတ်ရက် ကျော်လွန်)", + ["report.showingDelayedTasks"] = "ပြသနေသည်", + ["report.delayedOrOverdueTasks"] = "နှောင့်နှေး / ရက်လွန် လုပ်ငန်း(များ)", + ["report.delayEscalation"] = "နှောင့်နှေးမှု / အဆင့်မြှင့်တင်မှု", + ["report.noReasonRecorded"] = "အကြောင်းပြချက် မှတ်တမ်းတင်ထားခြင်း မရှိပါ", + ["report.blockedBy"] = "ပိတ်ဆို့ထားသူ - ", + ["report.escalation"] = "အဆင့်မြှင့်တင်မှု - ", + ["report.capacitySupportNeeded"] = "လုပ်ဆောင်နိုင်စွမ်း အကူအညီ လိုအပ်နိုင်သည်", + ["report.delayedTasksCount"] = "နှောင့်နှေးနေသော လုပ်ငန်း(များ)", + ["report.needsAction"] = "လုပ်ဆောင်ရန် လိုအပ်သည်", + ["report.notOverdueYet"] = "ရက်လွန်ခြင်းမရှိသေးပါ", + ["report.earlyDelay"] = "အစောပိုင်း နှောင့်နှေးမှု", + ["report.highRisk"] = "အန္တရာယ် မြင့်မားသည်", + ["report.dueIn"] = "အတွင်း ပြီးစီးရန် ကျန်ရှိသည့်ရက်", + ["report.noOverdueTasksDesc"] = "အလွန်ကောင်းမွန်ပါသည်။ လက်ရှိစစ်ထုတ်မှုများအရ ရက်လွန်နေသော သို့မဟုတ် နှောင့်နှေးနိုင်ခြေရှိသော လုပ်ငန်းများ မရှိပါ။", + ["report.noOverdueTasks"] = "ရက်လွန်လုပ်ငန်းများ မရှိပါ", + ["report.daysOverdue"] = "ရက်လွန်နေသည့်ရက်", + ["common.searchByTaskAssigneeProject"] = "ခေါင်းစဉ်၊ တာဝန်ယူသူ သို့မဟုတ် စီမံကိန်းဖြင့် ရှာပါ...", + ["notification.system"] = "စနစ်", + ["notification.notifications"] = "အသိပေးချက်များ", + ["notification.unread"] = "မဖတ်ရသေး", + ["notification.clearRead"] = "ဖတ်ပြီးများ ရှင်းမည်", + ["notification.loading"] = "အသိပေးချက်များကို ဖွင့်နေသည်...", + ["notification.noneYet"] = "အသိပေးချက် မရှိသေးပါ", + ["notification.emptyState"] = "သင့်လုပ်ငန်းဆိုင်ရာ သတိပေးချက်များကို ဤနေရာတွင် ပြသပါမည်။", + ["notification.clearReadTitle"] = "ဖတ်ပြီးအသိပေးချက်များကို ရှင်းမည်လား?", + ["notification.clearReadMessage"] = "သင့်စာရင်းမှ ဖတ်ပြီးသား အသိပေးချက်များအားလုံးကို အပြီးတိုင် ဖယ်ရှားမည်ဖြစ်သည်။", + ["report.projectProgressTitle"] = "စီမံကိန်း တိုးတက်မှု အစီရင်ခံစာ", + ["report.projectProgressHeading"] = "စီမံကိန်း တိုးတက်မှု", + ["report.projectProgressDesc"] = "လုပ်ငန်းစီစဉ်မှုများနှင့် နေ့စဉ်လုပ်ငန်းပြဿနာများ နှစ်ခုစလုံးကို အသုံးပြု၍ စီမံကိန်းကျန်းမာရေးကို စောင့်ကြည့်ပါ။", + ["report.searchProject"] = "စီမံကိန်း ရှာဖွေရန်", + ["report.periodProjectStart"] = "ကာလ (စီမံကိန်း စတင်ရက်)", + ["report.taskStatusCompletion"] = "လုပ်ငန်း အခြေအနေ ပြီးစီးမှု", + ["report.totalIssues"] = "စုစုပေါင်း ပြဿနာများ", + ["report.overdueIssues"] = "ရက်လွန် ပြဿနာများ", + ["report.blockedIssues"] = "ပိတ်ဆို့ထားသော ပြဿနာများ", + ["report.overdueIssuesNeedAttention"] = "ရက်လွန်ပြဿနာများကို ဂရုပြုရန် လိုအပ်သည်။", + ["report.issuesAreBlocked"] = "ပြဿနာများ ပိတ်ဆို့နေပါသည်။", + ["report.actualHoursAre"] = "အမှန်တကယ် လုပ်ဆောင်ချိန်မှာ", + ["report.against"] = "ဖြစ်ပြီး နှိုင်းယှဉ်ချက်မှာ", + ["report.searchEmployee"] = "ဝန်ထမ်း ရှာဖွေရန်", + ["report.employeeWorkloadTitle"] = "ဝန်ထမ်း အလုပ်ပမာဏနှင့် စွမ်းဆောင်ရည်", + ["report.employeeWorkloadHeading"] = "ဝန်ထမ်း အလုပ်ပမာဏနှင့် စွမ်းဆောင်ရည်", + ["report.employeeWorkloadDesc"] = "ပြဿနာအလုပ်ပမာဏ၊ လုပ်ဆောင်မှုကျန်းမာရေးနှင့် အမှန်တကယ်အားစိုက်ထုတ်မှုတို့ကို စောင့်ကြည့်ပါ။", + ["report.delaysByProject"] = "စီမံကိန်းအလိုက် နှောင့်နှေးမှုများ", + ["report.delaysByProjectDesc"] = "နှောင့်နှေးမှုစုစည်းမှု အများဆုံးဖြစ်သော စီမံကိန်းနယ်ပယ်များ။", + ["report.delaysByProjectInsight"] = "စီမံကိန်းများတစ်လျှောက် အစီအစဉ်ဆွဲခြင်းဆိုင်ရာ ဖိအားများကို ပြန်လည်မျှညှိရန် ဤအရာကို အသုံးပြုပါ။", + ["report.delaysByAssignee"] = "တာဝန်ယူသူအလိုက် နှောင့်နှေးမှုများ", + ["report.delaysByAssigneeDesc"] = "ပံ့ပိုးမှု သို့မဟုတ် နယ်ပယ်ပြင်ဆင်မှု လိုအပ်နိုင်သော အဖွဲ့ဝင်များ။", + ["report.delaysByAssigneeInsight"] = "ပံ့ပိုးမှုအချက်ပြချက်သာဖြစ်သည် - များပြားသောအရေအတွက်သည် အကူအညီပေးရန်ဖြစ်ပြီး အပြစ်တင်ရန်မဟုတ်ပါ။", + ["report.pastDueDate"] = "သတ်မှတ်ရက် ကျော်လွန်နေသည်", + ["report.highPriorityDueSoon"] = "ဦးစားပေးမြင့်ပြီး မကြာမီ ပြီးဆုံးရမည်", + ["report.severityVsDaysOverdue"] = "ပြင်းထန်မှု နှင့် ရက်လွန်ရက်များ", + ["report.severityVsDaysOverdueDesc"] = "ဦးစားပေးမြင့်မားပြီး ပိုမိုကြာမြင့်သော နှောင့်နှေးမှုများသည် ညာဘက်အပေါ်ရှိ အန္တရာယ်ဇုန်တွင် တည်ရှိသည်။", + ["report.severityMatrixInsight"] = "ဤမက်ထရစ်သည် ဦးစားပေးပြင်းထန်မှုကို နှောင့်နှေးချိန်သက်တမ်းနှင့် ပေါင်းစပ်ကာ ဖြေရှင်းရမည့် အစီအစဉ်ကို ပြသသည်။" + }; + + private static readonly IReadOnlyDictionary ErrorMessageKeys = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Invalid username/email or password."] = "result.invalidCredentials", + ["Failed to parse authentication response tokens."] = "result.invalidCredentials", + ["Please fill in all required fields."] = "validation.fillAllFields", + ["Registration failed. Please try again."] = "result.registrationFailed", + ["Failed to parse registration response."] = "result.registrationFailed", + ["Username is already taken."] = "result.usernameTaken", + ["Email is already registered."] = "result.emailRegistered", + ["No system roles configured. Registration failed."] = "result.noSystemRoles", + ["Unable to reach the API server. Please ensure TaskTrackingSystem.WebApi is running."] = "result.apiUnavailable", + ["Unable to reach the API server. Start TaskTrackingSystem.WebApi and try again."] = "result.apiUnavailable", + ["Failed to reset password."] = "result.resetPasswordFailed", + ["Unable to reset password."] = "result.resetPasswordFailed", + ["Invalid recovery code."] = "result.invalidRecoveryCode", + ["Account not found."] = "result.accountNotFound", + ["Username must be at least 3 characters long."] = "validation.usernameMinLength", + ["Username cannot contain spaces."] = "validation.usernameNoSpaces", + ["Username cannot contain spaces, and can only contain letters, numbers, underscores (_), and periods (.)"] = "validation.usernameInvalidCharacters", + ["Username cannot contain spaces, and can only contain letters, numbers, underscores (_), and periods (.)."] = "validation.usernameInvalidCharacters", + ["Password must be at least 6 characters."] = "validation.passwordMinLength", + ["Password must be at least 8 characters long."] = "validation.passwordMinLengthRule", + ["Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."] = "validation.passwordComplexityRule", + ["Failed to update assignee."] = "task.failedUpdateAssignee", + ["Failed to load project members."] = "task.failedLoadProjectMembers", + ["Task assignee must belong to the selected project."] = "task.assigneeMustBelong", + ["At least one task title is required."] = "task.titleRequiredEitherLanguage", + ["You do not have permission to update tasks."] = "result.noPermissionUpdateTasks" + }; + + private static readonly IReadOnlyDictionary ValidationFieldNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Username"] = "common.username", + ["Email"] = "common.email", + ["FirstName"] = "auth.firstName", + ["LastName"] = "auth.lastName", + ["Password"] = "common.password", + ["NewPassword"] = "auth.newPassword", + ["RecoveryCode"] = "auth.recoveryCode", + ["UsernameOrEmail"] = "common.usernameOrEmail", + ["Phone"] = "common.phone" + }; + + 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 ErrorMessage(string? message, string fallback = "") + { + if (string.IsNullOrWhiteSpace(message)) + { + return Text("common.error", string.IsNullOrWhiteSpace(fallback) ? "Error" : fallback); + } + + var trimmed = message.Trim(); + if (ErrorMessageKeys.TryGetValue(trimmed, out var key)) + { + return Text(key, trimmed); + } + + var requiredMessage = TryLocalizeRequiredFieldMessage(trimmed); + if (!string.IsNullOrWhiteSpace(requiredMessage)) + { + return requiredMessage; + } + + var localized = trimmed; + foreach (var mappedError in ErrorMessageKeys) + { + if (localized.Contains(mappedError.Key, StringComparison.OrdinalIgnoreCase)) + { + localized = localized.Replace(mappedError.Key, Text(mappedError.Value, mappedError.Key), StringComparison.OrdinalIgnoreCase); + } + } + + foreach (var field in ValidationFieldNames) + { + var requiredError = $"The {field.Key} field is required."; + if (localized.Contains(requiredError, StringComparison.OrdinalIgnoreCase)) + { + var fieldLabel = Text(field.Value, field.Key); + var translated = IsBurmeseCulture ? $"{fieldLabel} လိုအပ်ပါသည်။" : $"{fieldLabel} is required."; + localized = localized.Replace(requiredError, translated, StringComparison.OrdinalIgnoreCase); + } + } + + if (!string.Equals(localized, trimmed, StringComparison.Ordinal)) + { + return localized; + } + + return trimmed; + } + + private static string? TryLocalizeRequiredFieldMessage(string message) + { + const string requiredPrefix = "The "; + const string requiredSuffix = " field is required."; + + if (!message.StartsWith(requiredPrefix, StringComparison.Ordinal) || + !message.EndsWith(requiredSuffix, StringComparison.Ordinal)) + { + return null; + } + + var fieldName = message[requiredPrefix.Length..^requiredSuffix.Length]; + var fieldLabel = ValidationFieldNames.TryGetValue(fieldName, out var fieldKey) + ? Text(fieldKey, fieldName) + : fieldName; + + return IsBurmeseCulture + ? $"{fieldLabel} လိုအပ်ပါသည်။" + : $"{fieldLabel} is required."; + } + + public static LocalizationCatalogValidation ValidateCatalogs(IEnumerable? referencedKeys = null) + { + var englishKeys = English.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var burmeseKeys = Burmese.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var referenced = referencedKeys?.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + + return new LocalizationCatalogValidation( + englishKeys.Except(burmeseKeys, StringComparer.OrdinalIgnoreCase).OrderBy(key => key).ToArray(), + burmeseKeys.Except(englishKeys, StringComparer.OrdinalIgnoreCase).OrderBy(key => key).ToArray(), + Array.Empty(), + referenced.Count == 0 + ? Array.Empty() + : englishKeys.Concat(burmeseKeys).Distinct(StringComparer.OrdinalIgnoreCase) + .Except(referenced, StringComparer.OrdinalIgnoreCase) + .OrderBy(key => key) + .ToArray()); + } + + public static string LocalizeDigits(string? value) + { + if (!IsBurmeseCulture || string.IsNullOrEmpty(value)) + { + return value ?? string.Empty; + } + + return value + .Replace('0', '၀') + .Replace('1', '၁') + .Replace('2', '၂') + .Replace('3', '၃') + .Replace('4', '၄') + .Replace('5', '၅') + .Replace('6', '၆') + .Replace('7', '၇') + .Replace('8', '၈') + .Replace('9', '၉'); + } + + public static string Number(int value) => LocalizeDigits(value.ToString(CultureInfo.InvariantCulture)); + + public static string Number(long value) => LocalizeDigits(value.ToString(CultureInfo.InvariantCulture)); + + public static string Number(decimal value, string format = "0.#") => LocalizeDigits(value.ToString(format, CultureInfo.InvariantCulture)); + + public static string Number(double value, string format = "0.#") => LocalizeDigits(value.ToString(format, CultureInfo.InvariantCulture)); + + public static string MonthName(DateTime value, bool abbreviated = false) + { + if (!IsBurmeseCulture) + { + return value.ToString(abbreviated ? "MMM" : "MMMM", CultureInfo.InvariantCulture); + } + + var months = abbreviated + ? new[] { "ဇန်", "ဖေ", "မတ်", "ဧပြီ", "မေ", "ဇွန်", "ဇူလိုင်", "သြဂုတ်", "စက်", "အောက်", "နို", "ဒီ" } + : new[] { "ဇန်နဝါရီ", "ဖေဖော်ဝါရီ", "မတ်", "ဧပြီ", "မေ", "ဇွန်", "ဇူလိုင်", "သြဂုတ်", "စက်တင်ဘာ", "အောက်တိုဘာ", "နိုဝင်ဘာ", "ဒီဇင်ဘာ" }; + + return months[value.Month - 1]; + } + + 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 StatusName(string? statusName) + { + if (string.IsNullOrWhiteSpace(statusName)) + { + return Text("common.unknown", "Unknown"); + } + + return statusName.Trim().Replace(" ", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant() switch + { + "todo" => Text("status.todo", "To Do"), + "inprogress" => Text("status.inprogress", "In Progress"), + "done" => Text("status.done", "Done"), + "active" => Text("status.active", "Active"), + "inactive" => Text("status.inactive", "Inactive"), + "overdue" => Text("status.overdue", "Overdue"), + "blocked" => Text("status.blocked", "Blocked"), + "resolved" => Text("status.resolved", "Resolved"), + "clear" => Text("status.clear", "Clear"), + _ => statusName + }; + } + + public static string PriorityName(string? priorityName) + { + if (string.IsNullOrWhiteSpace(priorityName)) + { + return Text("common.unknown", "Unknown"); + } + + return priorityName.Trim().ToLowerInvariant() switch + { + "high" => Text("priority.high", "High"), + "medium" => Text("priority.medium", "Medium"), + "low" => Text("priority.low", "Low"), + _ => priorityName + }; + } + + public static string ActionName(string? actionName) + { + if (string.IsNullOrWhiteSpace(actionName)) + { + return Text("common.unknown", "Unknown"); + } + + return actionName.Trim().ToLowerInvariant() switch + { + "create" or "created" or "add" or "added" => Text("action.create", "Create"), + "update" or "updated" or "edit" or "edited" => Text("action.update", "Update"), + "delete" or "deleted" => Text("action.delete", "Delete"), + "assign" or "assigned" or "assignaccess" => Text("action.assign", "Assign"), + "archive" or "archived" => Text("action.archiveDone", "Archive Done"), + "login" => Text("common.signIn", "Sign In"), + "logout" => Text("common.signOut", "Sign Out"), + _ => actionName + }; + } + + public static string ModuleName(string? moduleName) + { + if (string.IsNullOrWhiteSpace(moduleName)) + { + return Text("common.unknown", "Unknown"); + } + + return moduleName.Trim().Replace(" ", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant() switch + { + "project" or "projects" => Text("page.projects", "Projects"), + "task" or "tasks" => Text("page.tasks", "Tasks"), + "user" or "users" => Text("page.users", "Users"), + "role" or "roles" => Text("page.rolesAccess", "Roles & Access"), + "issue" or "issues" => Text("page.issues", "Issues"), + "audit" or "auditlog" or "auditlogs" => Text("page.auditLogs", "Audit Logs"), + "dashboard" => Text("page.dashboard", "Dashboard"), + "report" or "reports" => Text("page.reports", "Reports"), + "module" => Text("common.module", "Module"), + _ => moduleName + }; + } + + public static string AuditDescription(string? description) + { + if (string.IsNullOrWhiteSpace(description)) + { + return Text("common.noneRecorded", "None recorded"); + } + + var text = LocalizeDigits(description); + if (!IsBurmeseCulture) + { + return text; + } + + return text switch + { + var value when value.StartsWith("Created task ", StringComparison.OrdinalIgnoreCase) => $"လုပ်ငန်း {value["Created task ".Length..]} ကို ဖန်တီးခဲ့သည်", + var value when value.StartsWith("Updated task ", StringComparison.OrdinalIgnoreCase) => $"လုပ်ငန်း {value["Updated task ".Length..]} ကို ပြင်ဆင်ခဲ့သည်", + var value when value.StartsWith("Deleted task ", StringComparison.OrdinalIgnoreCase) => $"လုပ်ငန်း {value["Deleted task ".Length..]} ကို ဖျက်ခဲ့သည်", + var value when value.StartsWith("Updated project ", StringComparison.OrdinalIgnoreCase) => $"စီမံကိန်း {value["Updated project ".Length..]} ကို ပြင်ဆင်ခဲ့သည်", + var value when value.StartsWith("Created user ", StringComparison.OrdinalIgnoreCase) => $"အသုံးပြုသူ {value["Created user ".Length..]} ကို ဖန်တီးခဲ့သည်", + var value when value.StartsWith("Deleted user account ", StringComparison.OrdinalIgnoreCase) => $"အသုံးပြုသူအကောင့် {value["Deleted user account ".Length..]} ကို ဖျက်ခဲ့သည်", + var value when value.StartsWith("Updated role name/description for ", StringComparison.OrdinalIgnoreCase) => $"အခန်းကဏ္ဍအမည်/ဖော်ပြချက် {value["Updated role name/description for ".Length..]} ကို ပြင်ဆင်ခဲ့သည်", + var value when value.StartsWith("Created project ", StringComparison.OrdinalIgnoreCase) => $"စီမံကိန်း {value["Created project ".Length..]} ကို ဖန်တီးခဲ့သည်", + var value when value.StartsWith("Deleted project ", StringComparison.OrdinalIgnoreCase) => $"စီမံကိန်း {value["Deleted project ".Length..]} ကို ဖျက်ခဲ့သည်", + var value when value.StartsWith("Created new role ", StringComparison.OrdinalIgnoreCase) => $"အခန်းကဏ္ဍ {value["Created new role ".Length..]} ကို ဖန်တီးခဲ့သည်", + var value when value.StartsWith("Deleted role ", StringComparison.OrdinalIgnoreCase) => $"အခန်းကဏ္ဍ {value["Deleted role ".Length..]} ကို ဖျက်ခဲ့သည်", + var value when value.StartsWith("Updated access permissions for role ", StringComparison.OrdinalIgnoreCase) => $"အခန်းကဏ္ဍအသုံးပြုခွင့်များ ပြင်ဆင်ခဲ့သည်။ {value["Updated access permissions for role ".Length..]}", + var value when value.StartsWith("Updated user ", StringComparison.OrdinalIgnoreCase) => $"အသုံးပြုသူ {value["Updated user ".Length..]} ကို ပြင်ဆင်ခဲ့သည်", + _ => text + }; + } + + public static string EscalationLabel(int level) => level switch + { + 1 => IsBurmeseCulture ? "နိမ့်" : "Low", + 2 => IsBurmeseCulture ? "အလယ်" : "Medium", + 3 => IsBurmeseCulture ? "မြင့်" : "High", + _ => Text("common.none", "None") + }; + + public static string LocalizeNotificationTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) return string.Empty; + if (!IsBurmeseCulture) return title; + + return title.Trim().ToLowerInvariant() switch + { + "task assigned" => "လုပ်ငန်းတာဝန် ပေးအပ်ခြင်း", + "task status changed" => "လုပ်ငန်းအခြေအနေ ပြောင်းလဲခြင်း", + "project assigned" => "စီမံကိန်းတာဝန် ပေးအပ်ခြင်း", + "new comment" => "မှတ်ချက်အသစ် ရေးသားခြင်း", + "you were mentioned" => "သင့်ကို ရည်ညွှန်းဖော်ပြခြင်း", + _ => title + }; + } + + public static string LocalizeNotificationBody(string? body) + { + if (string.IsNullOrWhiteSpace(body)) return string.Empty; + if (!IsBurmeseCulture) return body; + + if (body.StartsWith("You have been assigned a new task: ", StringComparison.OrdinalIgnoreCase)) + { + return $"သင့်အား လုပ်ငန်းသစ်တစ်ခု တာဝန်ပေးအပ်ထားပါသည် - {body["You have been assigned a new task: ".Length..]}"; + } + if (body.StartsWith("Task '", StringComparison.OrdinalIgnoreCase) && body.Contains("' changed from ")) + { + var match = System.Text.RegularExpressions.Regex.Match(body, @"^Task '(?.+)' changed from (?<oldStatus>.+) to (?<newStatus>.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (match.Success) + { + var title = match.Groups["title"].Value; + var oldStatus = StatusName(match.Groups["oldStatus"].Value); + var newStatus = StatusName(match.Groups["newStatus"].Value); + return $"လုပ်ငန်း '{title}' ၏အခြေအနေသည် '{oldStatus}' မှ '{newStatus}' သို့ ပြောင်းလဲသွားပါသည်"; + } + } + if (body.StartsWith("You have been assigned to project '", StringComparison.OrdinalIgnoreCase) && body.EndsWith("'")) + { + var projName = body["You have been assigned to project '".Length..^1]; + return $"သင့်အား စီမံကိန်း '{projName}' တွင် တာဝန်ပေးအပ်ထားပါသည်"; + } + if (body.Contains(" commented on task '") && body.EndsWith("'")) + { + var idx = body.IndexOf(" commented on task '", StringComparison.OrdinalIgnoreCase); + if (idx > 0) + { + var actor = body[..idx]; + var taskTitle = body[(idx + " commented on task '".Length)..^1]; + return $"{actor} သည် လုပ်ငန်း '{taskTitle}' တွင် မှတ်ချက်တစ်ခု ရေးသားခဲ့ပါသည်"; + } + } + if (body.Contains(" mentioned you in task '") && body.EndsWith("'")) + { + var idx = body.IndexOf(" mentioned you in task '", StringComparison.OrdinalIgnoreCase); + if (idx > 0) + { + var actor = body[..idx]; + var taskTitle = body[(idx + " mentioned you in task '".Length)..^1]; + return $"{actor} သည် လုပ်ငန်း '{taskTitle}' တွင် သင့်ကို ရည်ညွှန်းဖော်ပြခဲ့ပါသည်"; + } + } + + return LocalizeDigits(body); + } +} + +public sealed record LocalizationCatalogValidation( + IReadOnlyList<string> MissingInEnglish, + IReadOnlyList<string> MissingInBurmese, + IReadOnlyList<string> DuplicateKeys, + IReadOnlyList<string> UnreferencedKeys); + diff --git a/TaskTrackingSystem.Shared/Localization/ContentTranslation.cs b/TaskTrackingSystem.Shared/Localization/ContentTranslation.cs new file mode 100644 index 0000000000000000000000000000000000000000..d00772c887c809f010435579f0d9261eb42fab93 --- /dev/null +++ b/TaskTrackingSystem.Shared/Localization/ContentTranslation.cs @@ -0,0 +1,48 @@ +namespace TaskTrackingSystem.Shared.Localization; + +public sealed record TranslationResult( + bool Success, + string? TranslatedText, + string? ErrorMessage, + string? Provider, + bool WasGenerated) +{ + public static TranslationResult NotConfigured() => + new(false, null, "Translation provider is not configured.", "none", false); + + public bool Succeeded => Success; + public string? Text => TranslatedText; + + public static TranslationResult Generated(string text, string provider) => + new(true, text, null, provider, true); +} + +public interface IContentTranslationService +{ + Task<TranslationResult> TranslateAsync( + string sourceText, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken = default); + + Task<TranslationResult> TransliterateNameAsync( + string sourceName, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken = default); +} + +public sealed record TranslationRequest( + string Text, + string SourceLanguage, + string TargetLanguage, + bool IsName = false); + +public sealed class NoOpContentTranslationService : IContentTranslationService +{ + public Task<TranslationResult> TranslateAsync(string sourceText, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => + Task.FromResult(TranslationResult.NotConfigured()); + + public Task<TranslationResult> TransliterateNameAsync(string sourceName, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => + Task.FromResult(TranslationResult.NotConfigured()); +} diff --git a/TaskTrackingSystem.Shared/Localization/LanguageDetection.cs b/TaskTrackingSystem.Shared/Localization/LanguageDetection.cs new file mode 100644 index 0000000000000000000000000000000000000000..5e8188fa4e503325b1284e5e3c8a4a825c727d84 --- /dev/null +++ b/TaskTrackingSystem.Shared/Localization/LanguageDetection.cs @@ -0,0 +1,81 @@ +using System.Text; + +namespace TaskTrackingSystem.Shared.Localization; + +public enum DetectedContentLanguage +{ + Empty, + Burmese, + English, + Mixed, + Unknown +} + +public sealed record LanguageDetectionResult( + DetectedContentLanguage Language, + int BurmeseCharacters, + int LatinCharacters, + int OtherCharacters, + double BurmeseRatio, + double EnglishRatio); + +/// <summary> +/// Local, advisory detection used only to help users move content between fields. +/// It deliberately does not reject mixed-language business text. +/// </summary> +public sealed class LanguageDetectionService +{ + public LanguageDetectionResult Detect(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return new(DetectedContentLanguage.Empty, 0, 0, 0, 0, 0); + } + + var burmese = 0; + var latin = 0; + var other = 0; + + foreach (var rune in text.EnumerateRunes()) + { + if (IsBurmese(rune.Value)) + { + burmese++; + } + else if ((rune.Value >= 'A' && rune.Value <= 'Z') || (rune.Value >= 'a' && rune.Value <= 'z')) + { + latin++; + } + else if (!Rune.IsWhiteSpace(rune) && !Rune.IsPunctuation(rune) && !Rune.IsNumber(rune) && !Rune.IsSymbol(rune)) + { + other++; + } + } + + var letters = burmese + latin + other; + if (letters == 0) + { + return new(DetectedContentLanguage.Unknown, burmese, latin, other, 0, 0); + } + + var burmeseRatio = burmese / (double)letters; + var englishRatio = latin / (double)letters; + var language = burmeseRatio >= 0.70 && burmese > 0 + ? DetectedContentLanguage.Burmese + : englishRatio >= 0.70 && latin > 0 + ? DetectedContentLanguage.English + : burmese > 0 && latin > 0 + ? DetectedContentLanguage.Mixed + : DetectedContentLanguage.Unknown; + + return new(language, burmese, latin, other, burmeseRatio, englishRatio); + } + + public bool AppearsPredominantlyBurmese(string? text) => Detect(text).Language == DetectedContentLanguage.Burmese; + public bool AppearsPredominantlyEnglish(string? text) => Detect(text).Language == DetectedContentLanguage.English; + + private static bool IsBurmese(int value) => + (value >= 0x1000 && value <= 0x109F) || + (value >= 0xAA60 && value <= 0xAA7F) || + (value >= 0xA9E0 && value <= 0xA9FF); +} diff --git a/TaskTrackingSystem.Shared/Models/AuditLog/AuditLogDto.cs b/TaskTrackingSystem.Shared/Models/AuditLog/AuditLogDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..046fb0ee211f2f8a10c5cb3bf61bda95f8a9cca3 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/AuditLog/AuditLogDto.cs @@ -0,0 +1,18 @@ +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? UserFullNameMy { 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; } + } +} 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..d6a43a92d77bc0f48dc75072bba9028c957a4559 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Comment/CommentDto.cs @@ -0,0 +1,25 @@ +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? UserFullNameMy { get; set; } + public string UserRoleName { get; set; } = null!; + public string? UserRoleNameMy { get; set; } + public string Message { get; set; } = null!; + public string? MessageMy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } + + public class CreateCommentDto + { + public string Message { get; set; } = null!; + public string? MessageMy { get; set; } + } +} 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..a8dbb337e4f1ae505a090a96bba065cf24fa9972 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Dashboard/ProjectProgressDto.cs @@ -0,0 +1,12 @@ +namespace TaskTrackingSystem.Shared.Models.Dashboard +{ + public class ProjectProgressDto + { + public long ProjectId { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string? ProjectNameMy { get; set; } + 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..61cddc9c329e29dfd0a7d994805150735a0308c3 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Issue/CreateIssueDto.cs @@ -0,0 +1,89 @@ +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; } + + [MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(200)] + public string? TitleMy { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [MaxLength(1000)] + public string? DescriptionMy { 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; } + + [MaxLength(300)] + public string? DelayReasonMy { get; set; } + + public bool IsBlocked { get; set; } + + [MaxLength(200)] + public string? BlockedBy { get; set; } + + [MaxLength(200)] + public string? BlockedByMy { 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<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(TitleMy)) + { + yield return new ValidationResult("At least one issue title is required.", new[] { nameof(Title), nameof(TitleMy) }); + } + + 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..a93ddb5281c089cd1b8b77dd72ae907a57b2b12f --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Issue/IssueDto.cs @@ -0,0 +1,37 @@ +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 string? TaskTitleMy { get; set; } + public long ProjectId { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string? ProjectNameMy { get; set; } + public string Title { get; set; } = string.Empty; + public string? TitleMy { get; set; } + public string? Description { get; set; } + public string? DescriptionMy { get; set; } + public long? AssignedTo { get; set; } + public string? AssignedToName { get; set; } + public string? AssignedToNameMy { get; set; } + public decimal? EstimatedHours { get; set; } + public decimal? ActualHours { get; set; } + public string? DelayReason { get; set; } + public string? DelayReasonMy { get; set; } + public bool IsBlocked { get; set; } + public string? BlockedBy { get; set; } + public string? BlockedByMy { 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..9b0110917863ea398f2fabce895194abae23b246 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Issue/UpdateIssueDto.cs @@ -0,0 +1,85 @@ +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 + { + [MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(200)] + public string? TitleMy { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [MaxLength(1000)] + public string? DescriptionMy { 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; } + + [MaxLength(300)] + public string? DelayReasonMy { get; set; } + + public bool IsBlocked { get; set; } + + [MaxLength(200)] + public string? BlockedBy { get; set; } + + [MaxLength(200)] + public string? BlockedByMy { 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<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(TitleMy)) + { + yield return new ValidationResult("At least one issue title is required.", new[] { nameof(Title), nameof(TitleMy) }); + } + + 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..4a1b0ad2eb3561b269c4f20a81e88e5ccb124e0f --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Menu/AccessMenuDto.cs @@ -0,0 +1,16 @@ +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? MenuNameMy { get; set; } + 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<AccessPermissionDto> 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..1ac7c0a7c6df8baef4bf3694cb5d38163ceb7baf --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Menu/AccessPermissionDto.cs @@ -0,0 +1,17 @@ +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? ActionNameMy { get; set; } + 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..ac3176fa729e4ced33141bd3edab9b3ae08fd1bc --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Menu/MenuDto.cs @@ -0,0 +1,17 @@ +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? MenuNameMy { get; set; } + public string? MenuUrl { get; set; } + public int OrderNo { get; set; } + public string? Icon { get; set; } + public List<MenuDto> SubMenus { get; set; } = new List<MenuDto>(); + } +} diff --git a/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs b/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..f6b7ff57d6067ac18ae4da023dc7853fee337116 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs @@ -0,0 +1,21 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Notification; + +public class NotificationDto +{ + public long Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? TitleMy { get; set; } + public string Body { get; set; } = string.Empty; + public string? BodyMy { get; set; } + 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; } + public string? SenderNameMy { 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<long> UserIds { get; set; } = new List<long>(); + } +} diff --git a/TaskTrackingSystem.Shared/Models/Project/CreateProjectDto.cs b/TaskTrackingSystem.Shared/Models/Project/CreateProjectDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..4d20aae99cab362a795200f06b53520d6d3ce739 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Project/CreateProjectDto.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Project +{ + public class CreateProjectDto : IValidatableObject + { + [MaxLength(150)] + public string Name { get; set; } = string.Empty; + + [MaxLength(150)] + public string? NameMy { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + [MaxLength(500)] + public string? DescriptionMy { 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<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Name) && string.IsNullOrWhiteSpace(NameMy)) + { + yield return new ValidationResult("At least one project name is required.", new[] { nameof(Name), nameof(NameMy) }); + } + + 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..1754a9293b4275c1496df9f48a37c25f06066bd2 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Project/ProjectDto.cs @@ -0,0 +1,22 @@ +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? NameMy { get; set; } + public string? Description { get; set; } + public string? DescriptionMy { 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..e60de1f40b679e9679dcb80f5147d0d6fb7f0d24 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Project/UpdateProjectDto.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Project +{ + public class UpdateProjectDto : IValidatableObject + { + [MaxLength(150)] + public string Name { get; set; } = string.Empty; + + [MaxLength(150)] + public string? NameMy { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + [MaxLength(500)] + public string? DescriptionMy { get; set; } + + [Required] + public DateTime StartDate { get; set; } + + [Required] + public DateTime EndDate { get; set; } + + [Range(0, 100000)] + public int? BudgetedHours { get; set; } + + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Name) && string.IsNullOrWhiteSpace(NameMy)) + { + yield return new ValidationResult("At least one project name is required.", new[] { nameof(Name), nameof(NameMy) }); + } + + 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..769141ee7af3a2a92bc74d5271fee0f34a6e6752 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/EmployeeProductivityReportDto.cs @@ -0,0 +1,21 @@ +namespace TaskTrackingSystem.Shared.Models.Report; + +public class EmployeeProductivityReportDto +{ + public long UserId { get; set; } + public string FullName { get; set; } = string.Empty; + public string? FullNameMy { get; set; } + 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..037ec19522e72f64bae84e3a0eb1c3502de1fd56 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/OverdueCriticalTaskDto.cs @@ -0,0 +1,30 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class OverdueCriticalTaskDto + { + public long TaskId { get; set; } + public string Title { get; set; } = string.Empty; + public string? TitleMy { get; set; } + public string? Description { get; set; } + public string? DescriptionMy { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string? ProjectNameMy { get; set; } + public string StatusName { get; set; } = string.Empty; + public string PriorityName { get; set; } = string.Empty; + public string? AssignedTo { get; set; } + public string? AssignedToMy { 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? DelayReasonMy { get; set; } + public string BlockedBy { get; set; } = string.Empty; + public string? BlockedByMy { get; set; } + 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..c794439f869f36de471e95a28779a1c1d8151215 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/ProjectProgressReportDto.cs @@ -0,0 +1,22 @@ +namespace TaskTrackingSystem.Shared.Models.Report; + +public class ProjectProgressReportDto +{ + public long ProjectId { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string? ProjectNameMy { get; set; } + 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..884055d6e082b72f6763c6dce5b12cb4a7fc39e8 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs @@ -0,0 +1,35 @@ +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? TitleMy { get; set; } + public string? Description { get; set; } + public string? DescriptionMy { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string? ProjectNameMy { get; set; } + 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 string? AssignedToUserMy { get; set; } + public long? AssignedToUserId { get; set; } + public string? AssignedByUser { get; set; } + public string? AssignedByUserMy { 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 int BlockedIssues { 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..1dcfaae468fe966dfacfee1a6f2b5bea96e13cf7 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/TaskStatusSummaryDto.cs @@ -0,0 +1,20 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class TaskStatusSummaryDto + { + public long TaskId { get; set; } + public string Title { get; set; } = string.Empty; + public string? TitleMy { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string? ProjectNameMy { get; set; } + public string StatusName { get; set; } = string.Empty; + public string PriorityName { get; set; } = string.Empty; + public string? AssignedTo { get; set; } + public string? AssignedToMy { 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..4d0930bbeabd11b6d157a9adfb1d85ad91c97866 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/TeamProductivityReportDto.cs @@ -0,0 +1,16 @@ +namespace TaskTrackingSystem.Shared.Models.Report +{ + public class TeamProductivityReportDto + { + public long UserId { get; set; } + public string FullName { get; set; } = string.Empty; + public string? FullNameMy { get; set; } + 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..a7150eee9ec86747920ae1fbccd9a1652b19d007 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Report/UserProductivityDto.cs @@ -0,0 +1,14 @@ +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 string? FullNameMy { get; set; } + 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] + /// <summary> + /// Menu codes and permission codes selected for the role. + /// </summary> + public List<string> AccessCodes { get; set; } = new List<string>(); + } +} + diff --git a/TaskTrackingSystem.Shared/Models/Role/CreateRoleDto.cs b/TaskTrackingSystem.Shared/Models/Role/CreateRoleDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..21b3c619328b2a7425ac3639305253ce39c46b68 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Role/CreateRoleDto.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Role +{ + public class CreateRoleDto : IValidatableObject + { + [MaxLength(50)] + public string Name { get; set; } = string.Empty; + + [MaxLength(50)] + public string? NameMy { get; set; } + + [MaxLength(200)] + public string? Description { get; set; } + + [MaxLength(200)] + public string? DescriptionMy { get; set; } + + /// <summary> + /// Optional: Menu and permission codes to assign to this role on creation. + /// </summary> + public List<string> AccessCodes { get; set; } = new(); + + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Name) && string.IsNullOrWhiteSpace(NameMy)) + { + yield return new ValidationResult("At least one role name is required.", new[] { nameof(Name), nameof(NameMy) }); + } + } + } +} + diff --git a/TaskTrackingSystem.Shared/Models/Role/RoleDto.cs b/TaskTrackingSystem.Shared/Models/Role/RoleDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..4683a10c455c417d941d17c7195094ca9fb32bf6 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Role/RoleDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Role +{ + public class RoleDto + { + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? NameMy { get; set; } + public string? Description { get; set; } + public string? DescriptionMy { 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..9783624997fdc08d15f1f0e63a3e95aeef3c7b97 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Role/UpdateRoleDto.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace TaskTrackingSystem.Shared.Models.Role +{ + public class UpdateRoleDto : IValidatableObject + { + [MaxLength(50)] + public string Name { get; set; } = string.Empty; + + [MaxLength(50)] + public string? NameMy { get; set; } + + [MaxLength(200)] + public string? Description { get; set; } + + [MaxLength(200)] + public string? DescriptionMy { get; set; } + + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Name) && string.IsNullOrWhiteSpace(NameMy)) + { + yield return new ValidationResult("At least one role name is required.", new[] { nameof(Name), nameof(NameMy) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs b/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..86371a45a67a4ab4cff425ea5c2ad733291df5a6 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs @@ -0,0 +1,57 @@ +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 + { + [MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(200)] + public string? TitleMy { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [MaxLength(1000)] + public string? DescriptionMy { 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<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(TitleMy)) + { + yield return new ValidationResult("At least one task title is required.", new[] { nameof(Title), nameof(TitleMy) }); + } + + 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..edead8ccb88d99f62e9fed44327d36eb01a5b6c8 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs @@ -0,0 +1,27 @@ +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? TitleMy { get; set; } + public string? Description { get; set; } + public string? DescriptionMy { 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..c164b8537bfc9483143501d263b68426cfb7ef11 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs @@ -0,0 +1,55 @@ +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 + { + [MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [MaxLength(200)] + public string? TitleMy { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [MaxLength(1000)] + public string? DescriptionMy { 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<ValidationResult> Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(TitleMy)) + { + yield return new ValidationResult("At least one task title is required.", new[] { nameof(Title), nameof(TitleMy) }); + } + + 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..889e3dab8dcae06cadd9946f938c89e7008d7130 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/User/CreateUserDto.cs @@ -0,0 +1,53 @@ +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 : IValidatableObject + { + [Required, MaxLength(50)] + [MinLength(3, ErrorMessage = ResultMessages.UsernameMinLength)] + [RegularExpression(@"^[a-zA-Z0-9._]+$", ErrorMessage = ResultMessages.UsernameInvalidCharacters)] + public string Username { get; set; } = string.Empty; + + [MaxLength(50)] + public string FirstName { get; set; } = string.Empty; + + [MaxLength(50)] + public string LastName { get; set; } = string.Empty; + + [MaxLength(50)] + public string? FirstNameMy { get; set; } + + [MaxLength(50)] + public string? LastNameMy { get; set; } + + [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; } + + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) + { + var englishComplete = !string.IsNullOrWhiteSpace(FirstName) && !string.IsNullOrWhiteSpace(LastName); + var burmeseComplete = !string.IsNullOrWhiteSpace(FirstNameMy) && !string.IsNullOrWhiteSpace(LastNameMy); + if (!englishComplete && !burmeseComplete) + { + yield return new ValidationResult("At least one complete name is required.", new[] { nameof(FirstName), nameof(LastName), nameof(FirstNameMy), nameof(LastNameMy) }); + } + } + } + } diff --git a/TaskTrackingSystem.Shared/Models/User/UpdateUserDto.cs b/TaskTrackingSystem.Shared/Models/User/UpdateUserDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..6c190f88f1cf1067f596173684bbc2e0fe47099c --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/User/UpdateUserDto.cs @@ -0,0 +1,47 @@ +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 : IValidatableObject + { + [Required, MaxLength(50)] + [MinLength(3, ErrorMessage = ResultMessages.UsernameMinLength)] + [RegularExpression(@"^[a-zA-Z0-9._]+$", ErrorMessage = ResultMessages.UsernameInvalidCharacters)] + public string Username { get; set; } = string.Empty; + + [MaxLength(50)] + public string FirstName { get; set; } = string.Empty; + + [MaxLength(50)] + public string LastName { get; set; } = string.Empty; + + [MaxLength(50)] + public string? FirstNameMy { get; set; } + + [MaxLength(50)] + public string? LastNameMy { get; set; } + + [MaxLength(20)] + public string? Phone { get; set; } + + [Required] + public long RoleId { get; set; } + + public bool IsActive { get; set; } + + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) + { + var englishComplete = !string.IsNullOrWhiteSpace(FirstName) && !string.IsNullOrWhiteSpace(LastName); + var burmeseComplete = !string.IsNullOrWhiteSpace(FirstNameMy) && !string.IsNullOrWhiteSpace(LastNameMy); + if (!englishComplete && !burmeseComplete) + { + yield return new ValidationResult("At least one complete name is required.", new[] { nameof(FirstName), nameof(LastName), nameof(FirstNameMy), nameof(LastNameMy) }); + } + } + } +} diff --git a/TaskTrackingSystem.Shared/Models/User/UserDto.cs b/TaskTrackingSystem.Shared/Models/User/UserDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..70d9960f383db5e40e1042f3a7962c5349232abc --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/User/UserDto.cs @@ -0,0 +1,25 @@ +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? FirstNameMy { get; set; } + public string? LastNameMy { get; set; } + 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<T> +{ + public List<T> 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<T> + { + public bool IsSuccess { get; set; } + public T? Value { get; set; } + public string? ErrorMessage { get; set; } + public int StatusCode { get; set; } + + public static Result<T> Success(T value, int statusCode = 200) + { + return new Result<T> + { + IsSuccess = true, + Value = value, + StatusCode = statusCode + }; + } + + public static Result<T> Failure(string errorMessage, int statusCode = 400) + { + return new Result<T> + { + 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<string>? summaryLines, + IReadOnlyList<string> headers, + IEnumerable<IReadOnlyList<string>> rows) + { + var lines = new List<string>(); + + 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<string> 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<string> { "No data available." }); + } + + var objects = new List<string>(); + objects.Add("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + + var pageObjectIds = new List<int>(); + var contentObjectIds = new List<int>(); + 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<int> { 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<string> 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<string> 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<string> headers, + IEnumerable<IReadOnlyList<object?>> rows, + IEnumerable<string>? 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<string>(); + + 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() => + """ + <?xml version="1.0" encoding="UTF-8" standalone="yes"?> + <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> + <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" /> + <Default Extension="xml" ContentType="application/xml" /> + <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" /> + <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" /> + <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" /> + </Types> + """; + + private static string BuildRootRelsXml() => + """ + <?xml version="1.0" encoding="UTF-8" standalone="yes"?> + <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> + <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml" /> + </Relationships> + """; + + private static string BuildWorkbookXml(string worksheetName) + { + var safeName = EscapeXml(SanitizeWorksheetName(worksheetName)); + return $""" + <?xml version="1.0" encoding="UTF-8" standalone="yes"?> + <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" + xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> + <sheets> + <sheet name="{safeName}" sheetId="1" r:id="rId1" /> + </sheets> + </workbook> + """; + } + + private static string BuildWorkbookRelsXml() => + """ + <?xml version="1.0" encoding="UTF-8" standalone="yes"?> + <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> + <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml" /> + <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml" /> + </Relationships> + """; + + private static string BuildStylesXml() => + """ + <?xml version="1.0" encoding="UTF-8" standalone="yes"?> + <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> + <fonts count="3"> + <font> + <sz val="11" /> + <color theme="1" /> + <name val="Calibri" /> + <family val="2" /> + </font> + <font> + <b /> + <sz val="12" /> + <color rgb="FFFFFFFF" /> + <name val="Calibri" /> + <family val="2" /> + </font> + <font> + <i /> + <sz val="11" /> + <color rgb="FF475569" /> + <name val="Calibri" /> + <family val="2" /> + </font> + </fonts> + <fills count="3"> + <fill> + <patternFill patternType="none" /> + </fill> + <fill> + <patternFill patternType="gray125" /> + </fill> + <fill> + <patternFill patternType="solid"> + <fgColor rgb="FF5B21B6" /> + <bgColor indexed="64" /> + </patternFill> + </fill> + </fills> + <borders count="2"> + <border> + <left /> + <right /> + <top /> + <bottom /> + <diagonal /> + </border> + <border> + <left style="thin"><color rgb="FFE2E8F0" /></left> + <right style="thin"><color rgb="FFE2E8F0" /></right> + <top style="thin"><color rgb="FFE2E8F0" /></top> + <bottom style="thin"><color rgb="FFE2E8F0" /></bottom> + <diagonal /> + </border> + </borders> + <cellStyleXfs count="1"> + <xf numFmtId="0" fontId="0" fillId="0" borderId="0" /> + </cellStyleXfs> + <cellXfs count="4"> + <xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" /> + <xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"> + <alignment horizontal="center" vertical="center" /> + </xf> + <xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"> + <alignment vertical="center" /> + </xf> + <xf numFmtId="14" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1" applyAlignment="1"> + <alignment vertical="center" /> + </xf> + </cellXfs> + <cellStyles count="1"> + <cellStyle name="Normal" xfId="0" builtinId="0" /> + </cellStyles> + <dxfs count="0" /> + <tableStyles count="0" defaultTableStyle="TableStyleMedium2" defaultPivotStyle="PivotStyleLight16" /> + </styleSheet> + """; + + private static string BuildWorksheetXml( + string title, + IReadOnlyList<string> headers, + IReadOnlyList<object?[]> rows, + IReadOnlyList<string> 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("<row r=\""); + rowXml.Append(rowIndex); + 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("</row>"); + rowIndex++; + + foreach (var row in rows) + { + rowXml.Append("<row r=\""); + rowXml.Append(rowIndex); + 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("</row>"); + rowIndex++; + } + + var autoFilterRow = 1 + (string.IsNullOrWhiteSpace(title) ? 0 : 1) + summaryLines.Count + 1; + + var worksheetXml = new StringBuilder(); + worksheetXml.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"); + worksheetXml.AppendLine("<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\""); + worksheetXml.AppendLine(" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">"); + worksheetXml.AppendLine($" <dimension ref=\"{dimension}\" />"); + worksheetXml.AppendLine(" <sheetViews>"); + worksheetXml.AppendLine(" <sheetView workbookViewId=\"0\">"); + worksheetXml.AppendLine($" <pane ySplit=\"{autoFilterRow}\" topLeftCell=\"A{autoFilterRow + 1}\" activePane=\"bottomLeft\" state=\"frozen\" />"); + worksheetXml.AppendLine($" <selection pane=\"bottomLeft\" activeCell=\"A{autoFilterRow + 1}\" sqref=\"A{autoFilterRow + 1}\" />"); + worksheetXml.AppendLine(" </sheetView>"); + worksheetXml.AppendLine(" </sheetViews>"); + worksheetXml.AppendLine(" <sheetFormatPr defaultRowHeight=\"15\" />"); + worksheetXml.AppendLine(" <cols>"); + worksheetXml.Append(BuildColumnDefinitions(columnCount)); + worksheetXml.AppendLine(" </cols>"); + worksheetXml.AppendLine(" <sheetData>"); + worksheetXml.Append(rowXml); + worksheetXml.AppendLine(" </sheetData>"); + worksheetXml.AppendLine($" <autoFilter ref=\"A{autoFilterRow}:{ToColumnName(columnCount)}{rowIndex - 1}\" />"); + worksheetXml.AppendLine("</worksheet>"); + return worksheetXml.ToString(); + } + + private static string BuildSingleCellRow(int rowIndex, int colIndex, string value, int styleIndex) + { + return $"<row r=\"{rowIndex}\">{BuildInlineCell(rowIndex, colIndex + 1, value, styleIndex)}</row>\n"; + } + + private static string BuildColumnDefinitions(int columnCount) + { + var builder = new StringBuilder(); + for (var col = 1; col <= columnCount; col++) + { + builder.Append($" <col min=\"{col}\" max=\"{col}\" width=\"18\" customWidth=\"1\" />\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 $""" + <c r="{cellRef}" t="inlineStr" s="{styleIndex}"> + <is><t xml:space="preserve">{EscapeXml(value)}</t></is> + </c> + """; + } + + private static string BuildNumericCell(int rowIndex, int columnIndex, string value, int styleIndex = 0) + { + var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}"; + return $"""<c r="{cellRef}" s="{styleIndex}"><v>{value}</v></c>"""; + } + + private static string BuildBooleanCell(int rowIndex, int columnIndex, bool value) + { + var cellRef = $"{ToColumnName(columnIndex)}{rowIndex}"; + return $"""<c r="{cellRef}" t="b"><v>{(value ? "1" : "0")}</v></c>"""; + } + + 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..ca0a55b05d68fd7430cfe15afb544da7d30a83e6 --- /dev/null +++ b/TaskTrackingSystem.Shared/TaskTrackingSystem.Shared.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <GenerateMSBuildEditorConfigFile>false</GenerateMSBuildEditorConfigFile> + </PropertyGroup> + + <ItemGroup> + <Compile Remove="artifacts\**" /> + <None Remove="artifacts\**" /> + </ItemGroup> + + <ItemGroup> + <Folder Include="Models\Task\" /> + <Folder Include="Models\Project\" /> + <Folder Include="Models\Role\" /> + </ItemGroup> + +</Project> diff --git a/TaskTrackingSystem.WebApi/Features/Audit/AuditLogController.cs b/TaskTrackingSystem.WebApi/Features/Audit/AuditLogController.cs new file mode 100644 index 0000000000000000000000000000000000000000..dba07eb0155cc0a8d451a912df9f79bca5c6c42b --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Audit/AuditLogController.cs @@ -0,0 +1,154 @@ +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<AuditLogDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult> 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 searchTerm = search.Trim(); + var searchPattern = $"%{searchTerm}%"; + var normalizedSearch = searchTerm.Replace(" ", string.Empty, StringComparison.Ordinal) + .ToLowerInvariant(); + var searchModuleLabel = normalizedSearch is "module" or "အပိုင်း"; + var searchActionLabel = normalizedSearch is "actions" or "လုပ်ဆောင်ချက်များ"; + var searchRoleModule = normalizedSearch.Contains("role", StringComparison.Ordinal) || + searchTerm.Contains("အခန်းကဏ္ဍ", StringComparison.Ordinal); + var searchProjectModule = normalizedSearch.Contains("project", StringComparison.Ordinal) || + searchTerm.Contains("စီမံကိန်း", StringComparison.Ordinal); + var searchTaskModule = normalizedSearch.Contains("task", StringComparison.Ordinal) || + searchTerm.Contains("လုပ်ငန်း", StringComparison.Ordinal); + var searchUserModule = normalizedSearch.Contains("user", StringComparison.Ordinal) || + searchTerm.Contains("အသုံးပြုသူ", StringComparison.Ordinal); + var searchIssueModule = normalizedSearch.Contains("issue", StringComparison.Ordinal) || + searchTerm.Contains("ပြဿနာ", StringComparison.Ordinal); + var searchAuditModule = normalizedSearch.Contains("audit", StringComparison.Ordinal) || + searchTerm.Contains("မှတ်တမ်း", StringComparison.Ordinal); + var searchDashboardModule = normalizedSearch.Contains("dashboard", StringComparison.Ordinal) || + searchTerm.Contains("ပင်မစာမျက်နှာ", StringComparison.Ordinal); + var searchReportModule = normalizedSearch.Contains("report", StringComparison.Ordinal) || + searchTerm.Contains("အစီရင်ခံစာ", StringComparison.Ordinal); + var searchCreateAction = normalizedSearch is "create" or "created" or "add" or "added" || + searchTerm.Contains("ဖန်တီး", StringComparison.Ordinal); + var searchUpdateAction = normalizedSearch is "update" or "updated" or "edit" or "edited" || + searchTerm.Contains("အပ်ဒိတ်", StringComparison.Ordinal); + var searchDeleteAction = normalizedSearch is "delete" or "deleted" || + searchTerm.Contains("ဖျက်", StringComparison.Ordinal); + var searchAssignAction = normalizedSearch is "assign" or "assigned" or "assignaccess" || + searchTerm.Contains("ခွဲဝေ", StringComparison.Ordinal); + query = query.Where(a => + EF.Functions.ILike(a.Action ?? string.Empty, searchPattern) || + EF.Functions.ILike(a.Module ?? string.Empty, searchPattern) || + EF.Functions.ILike(a.Description ?? string.Empty, searchPattern) || + (searchModuleLabel && a.Module != null) || + (searchActionLabel && a.Action != null) || + (searchRoleModule && EF.Functions.ILike(a.Module ?? string.Empty, "%role%")) || + (searchProjectModule && EF.Functions.ILike(a.Module ?? string.Empty, "%project%")) || + (searchTaskModule && EF.Functions.ILike(a.Module ?? string.Empty, "%task%")) || + (searchUserModule && EF.Functions.ILike(a.Module ?? string.Empty, "%user%")) || + (searchIssueModule && EF.Functions.ILike(a.Module ?? string.Empty, "%issue%")) || + (searchAuditModule && EF.Functions.ILike(a.Module ?? string.Empty, "%audit%")) || + (searchDashboardModule && EF.Functions.ILike(a.Module ?? string.Empty, "%dashboard%")) || + (searchReportModule && EF.Functions.ILike(a.Module ?? string.Empty, "%report%")) || + (searchCreateAction && EF.Functions.ILike(a.Action ?? string.Empty, "%create%")) || + (searchUpdateAction && EF.Functions.ILike(a.Action ?? string.Empty, "%update%")) || + (searchDeleteAction && EF.Functions.ILike(a.Action ?? string.Empty, "%delete%")) || + (searchAssignAction && EF.Functions.ILike(a.Action ?? string.Empty, "%assign%")) || + (a.User != null && + (EF.Functions.ILike(a.User.Username ?? string.Empty, searchPattern) || + EF.Functions.ILike(a.User.FirstName ?? string.Empty, searchPattern) || + EF.Functions.ILike(a.User.LastName ?? string.Empty, searchPattern) || + EF.Functions.ILike((a.User.FirstName ?? string.Empty) + " " + (a.User.LastName ?? string.Empty), searchPattern) || + EF.Functions.ILike((a.User.FirstName ?? string.Empty) + (a.User.LastName ?? string.Empty), searchPattern) || + EF.Functions.ILike(a.User.FirstNameMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(a.User.LastNameMy ?? string.Empty, searchPattern) || + EF.Functions.ILike((a.User.FirstNameMy ?? string.Empty) + " " + (a.User.LastNameMy ?? string.Empty), searchPattern) || + EF.Functions.ILike((a.User.FirstNameMy ?? string.Empty) + (a.User.LastNameMy ?? string.Empty), searchPattern))) || + EF.Functions.ILike(a.IpAddress ?? string.Empty, searchPattern) + ); + } + + 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", + UserFullNameMy = a.User != null ? $"{a.User.FirstNameMy} {a.User.LastNameMy}" : null, + 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", + UserFullNameMy = a.User != null ? $"{a.User.FirstNameMy} {a.User.LastNameMy}" : null, + 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<IActionResult> 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<AuthResponseDto>.Failure(ResultMessages.InvalidCredentials, 401)); + } + + // Wrap the payload inside your official Result architecture + return Ok(Result<AuthResponseDto>.Success(response)); + } + catch (InvalidOperationException ex) + { + return BadRequest(Result<AuthResponseDto>.Failure(ex.Message, 400)); + } + } + + [HttpPost("register")] + public async Task<IActionResult> Register([FromBody] RegisterDto registerDto) + { + try + { + var response = await _authService.RegisterAsync(registerDto); + return Ok(Result<AuthResponseDto>.Success(response)); + } + catch (InvalidOperationException ex) + { + return BadRequest(Result<AuthResponseDto>.Failure(ex.Message, 400)); + } + } + + [HttpPost("reset-password")] + public async Task<IActionResult> 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<TaskTrackingSystem.Database.AppDbContextModels.User> _passwordHasher; + + public AuthService(AppDbContext db, IConfiguration configuration, IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User> passwordHasher) + { + _db = db; + _configuration = configuration; + _passwordHasher = passwordHasher; + } + + public async Task<AuthResponseDto?> 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<AuthResponseDto> 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<Result> 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<ActionResult<Result<DashboardSummaryDto>>> GetSummary() + { + var result = await _dashboardService.GetSummaryAsync(User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("tasks-overview")] + public async Task<ActionResult<Result<IEnumerable<TaskStatusOverviewDto>>>> GetTasksOverview() + { + var result = await _dashboardService.GetTasksOverviewAsync(User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("project-progress")] + public async Task<ActionResult<Result<IEnumerable<ProjectProgressDto>>>> 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..89649b7c63175f134634ee3307cbdd4cd5c502b7 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs @@ -0,0 +1,163 @@ +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<Result<DashboardSummaryDto>> 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<DashboardSummaryDto>.Success(summary); + } + + public async Task<Result<IEnumerable<TaskStatusOverviewDto>>> 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, string> + { + { 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<IEnumerable<TaskStatusOverviewDto>>.Success(overview.OrderBy(o => o.StatusId)); + } + + public async Task<Result<IEnumerable<ProjectProgressDto>>> GetProjectProgressAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + var activeProjects = await BuildAccessibleProjectQuery(isAdmin, currentUserId).ToListAsync(); + var progressList = new List<ProjectProgressDto>(); + + 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, + ProjectNameMy = project.NameMy, + TotalTasksCount = totalTasks, + CompletedTasksCount = completedTasks, + CompletionPercentage = percentage + }); + } + + return Result<IEnumerable<ProjectProgressDto>>.Success(progressList); + } + + private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Task> 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<TaskTrackingSystem.Database.AppDbContextModels.Project> 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<IssueDto>), StatusCodes.Status200OK)] + public async Task<ActionResult> 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<ActionResult<IssueDto>> 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<ActionResult<IEnumerable<IssueDto>>> GetIssuesByTask(long taskId) + { + var issues = await _issueService.GetIssuesByTaskIdAsync(taskId, User.GetRoleId(), User.GetUserId()); + return Ok(issues); + } + + [HttpPost] + public async Task<ActionResult<Result<IssueDto>>> CreateIssue([FromBody] CreateIssueDto dto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Issue", "Create")) + { + return StatusCode(403, Result<IssueDto>.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<ActionResult<Result>> 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<ActionResult<Result>> 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..d2272d927cb8149ea8b7c2aa4b12d0ad769c10d5 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Issue/IssueService.cs @@ -0,0 +1,383 @@ +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<IEnumerable<IssueDto>> 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<PagedResult<IssueDto>> 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(); + var searchPattern = $"%{searchTerm}%"; + query = query.Where(i => + EF.Functions.ILike(i.Title ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.TitleMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.Description ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.DescriptionMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.DelayReason ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.DelayReasonMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.BlockedBy ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.BlockedByMy ?? string.Empty, searchPattern) || + (i.AssignedToNavigation != null && + (EF.Functions.ILike(i.AssignedToNavigation.FirstName ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.AssignedToNavigation.LastName ?? string.Empty, searchPattern) || + EF.Functions.ILike((i.AssignedToNavigation.FirstName ?? string.Empty) + " " + (i.AssignedToNavigation.LastName ?? string.Empty), searchPattern) || + EF.Functions.ILike((i.AssignedToNavigation.FirstName ?? string.Empty) + (i.AssignedToNavigation.LastName ?? string.Empty), searchPattern) || + EF.Functions.ILike(i.AssignedToNavigation.FirstNameMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.AssignedToNavigation.LastNameMy ?? string.Empty, searchPattern) || + EF.Functions.ILike((i.AssignedToNavigation.FirstNameMy ?? string.Empty) + " " + (i.AssignedToNavigation.LastNameMy ?? string.Empty), searchPattern) || + EF.Functions.ILike((i.AssignedToNavigation.FirstNameMy ?? string.Empty) + (i.AssignedToNavigation.LastNameMy ?? string.Empty), searchPattern) || + EF.Functions.ILike(i.AssignedToNavigation.Username ?? string.Empty, searchPattern) || + EF.Functions.ILike(i.AssignedToNavigation.Email ?? string.Empty, searchPattern)))); + } + + 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<IssueDto?> 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<IEnumerable<IssueDto>> GetIssuesByTaskIdAsync(long taskId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await CanAccessTaskAsync(taskId, isAdmin, currentUserId)) + { + return Array.Empty<IssueDto>(); + } + + 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<Result<IssueDto>> CreateIssueAsync(CreateIssueDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Title) && string.IsNullOrWhiteSpace(dto.TitleMy)) + { + return Result<IssueDto>.Failure("Issue title is required.", 400); + } + + if (dto.StartDate == default || dto.DueDate == default) + { + return Result<IssueDto>.Failure("Issue start and due dates are required.", 400); + } + + if (dto.DueDate.Date < dto.StartDate.Date) + { + return Result<IssueDto>.Failure("Issue due date cannot be earlier than the start date.", 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<IssueDto>.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<IssueDto>.Failure("You do not have access to this task.", 403); + } + + if (dto.AssignedTo.HasValue && !await IsTaskMemberAsync(task.ProjectId, dto.AssignedTo.Value)) + { + return Result<IssueDto>.Failure("Issue assignee must belong to the selected project.", 400); + } + + var issue = new IssueEntity + { + TaskId = dto.TaskId, + Title = dto.Title?.Trim() ?? string.Empty, + TitleMy = dto.TitleMy, + Description = dto.Description, + DescriptionMy = dto.DescriptionMy, + AssignedTo = dto.AssignedTo, + EstimatedHours = dto.EstimatedHours, + ActualHours = dto.ActualHours, + DelayReason = dto.DelayReason, + DelayReasonMy = dto.DelayReasonMy, + IsBlocked = dto.IsBlocked, + BlockedBy = dto.BlockedBy, + BlockedByMy = dto.BlockedByMy, + 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<IssueDto>.Success(result!, 201); + } + + public async Task<Result> UpdateIssueAsync(long id, UpdateIssueDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Title) && string.IsNullOrWhiteSpace(dto.TitleMy)) + { + 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() ?? string.Empty; + issue.TitleMy = dto.TitleMy; + issue.Description = dto.Description; + issue.DescriptionMy = dto.DescriptionMy; + issue.AssignedTo = dto.AssignedTo; + issue.EstimatedHours = dto.EstimatedHours; + issue.ActualHours = dto.ActualHours; + issue.DelayReason = dto.DelayReason; + issue.DelayReasonMy = dto.DelayReasonMy; + issue.IsBlocked = dto.IsBlocked; + issue.BlockedBy = dto.BlockedBy; + issue.BlockedByMy = dto.BlockedByMy; + 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<Result> 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<IssueEntity> 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<bool> 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<bool> IsTaskMemberAsync(long projectId, long userId) + { + return await _db.ProjectMembers.AnyAsync(pm => pm.ProjectId == projectId && pm.UserId == userId); + } + + private static System.Linq.Expressions.Expression<Func<IssueEntity, IssueDto>> ToDtoProjection() + { + return i => new IssueDto + { + Id = i.Id, + TaskId = i.TaskId, + TaskTitle = i.Task.Title, + TaskTitleMy = i.Task.TitleMy, + ProjectId = i.Task.ProjectId, + ProjectName = i.Task.Project.Name, + ProjectNameMy = i.Task.Project.NameMy, + Title = i.Title, + TitleMy = i.TitleMy, + Description = i.Description, + DescriptionMy = i.DescriptionMy, + AssignedTo = i.AssignedTo, + AssignedToName = i.AssignedToNavigation != null + ? i.AssignedToNavigation.FirstName + " " + i.AssignedToNavigation.LastName + : null, + AssignedToNameMy = i.AssignedToNavigation != null + ? i.AssignedToNavigation.FirstNameMy + " " + i.AssignedToNavigation.LastNameMy + : null, + EstimatedHours = i.EstimatedHours, + ActualHours = i.ActualHours, + DelayReason = i.DelayReason, + DelayReasonMy = i.DelayReasonMy, + IsBlocked = i.IsBlocked, + BlockedBy = i.BlockedBy, + BlockedByMy = i.BlockedByMy, + 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<ActionResult<IEnumerable<MenuDto>>> 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<ActionResult<IEnumerable<AccessMenuDto>>> 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<ActionResult<IEnumerable<string>>> GetCurrentAccessCodes() + { + var roleId = User.GetRoleId(); + var roleName = User.GetRoleName() ?? string.Empty; + var codes = await _menuService.GetCurrentAccessCodesAsync(roleId, roleName); + return Ok(codes); + } + + /// <summary> + /// 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. + /// </summary> + [HttpGet("version")] + public async Task<ActionResult<string>> 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..7b3b75c852ce1126322d1574776d147df40c3dd4 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Menu/MenuService.cs @@ -0,0 +1,339 @@ +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<List<MenuDto>> GetMenuTreeByRoleIdAsync(long roleId) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true); + if (role == null) + { + return new List<MenuDto>(); + } + + return await GetMenuTreeForRoleAsync(role.Id); + } + + public async Task<List<MenuDto>> GetMenuTreeByRoleNameAsync(string roleName) + { + if (string.IsNullOrWhiteSpace(roleName)) + { + return new List<MenuDto>(); + } + + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Name == roleName && r.IsDeleted != true); + if (role == null) + { + return new List<MenuDto>(); + } + + return await GetMenuTreeForRoleAsync(role.Id); + } + + private async Task<List<MenuDto>> 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<MenuDto>(); + } + + 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<MenuDto> BuildHierarchy(List<MenuEntity> menus) + { + if (menus.Count == 0) + { + return new List<MenuDto>(); + } + + 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, + MenuNameMy = m.MenuNameMy, + MenuUrl = m.MenuUrl, + OrderNo = m.OrderNo, + Icon = m.Icon + }); + + foreach (var dto in dtoLookup.Values) + { + dto.SubMenus = new List<MenuDto>(); + } + + var roots = new List<MenuDto>(); + + 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<long, MenuEntity> menuLookup) + { + if (!menu.ParentMenuId.HasValue) + { + return string.Empty; + } + + return menuLookup.TryGetValue(menu.ParentMenuId.Value, out var parent) + ? parent.MenuCode + : string.Empty; + } + + private static HashSet<long> ExpandWithAncestors(IEnumerable<long> menuIds, IReadOnlyDictionary<long, MenuEntity> menuLookup) + { + var expanded = new HashSet<long>(); + + 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<MenuDto> 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<string> 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<List<AccessMenuDto>> 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, + MenuNameMy = m.MenuNameMy, + 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, + ActionNameMy = permission.ActionNameMy, + ApiName = permission.ApiName, + Visible = permission.Visible, + OrderNo = permission.OrderNo + }); + } + } + + return menuDtos; + } + + public async Task<List<string>> 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<string>(); + } + + 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..43a47a02703e50164826ff994fd0d44317244c60 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs @@ -0,0 +1,535 @@ +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(@"@(?<username>[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 titleMy = "လုပ်ငန်းတာဝန် ပေးအပ်ခြင်း"; + var body = $"You have been assigned a new task: {task.Title}"; + var bodyMy = $"သင့်အား လုပ်ငန်းသစ်တစ်ခု တာဝန်ပေးအပ်ထားပါသည် - {GetTaskTitleMy(task)}"; + + return SendAsync( + recipientIds: new[] { task.AssignedTo.Value }, + senderId: senderId, + notificationType: NotificationType.TaskAssigned, + sourceType: "task", + sourceId: task.Id, + title: title, + body: body, + titleMy: titleMy, + bodyMy: bodyMy); + } + + 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<long> recipientIds, long senderId) + { + var uniqueRecipientIds = recipientIds + .Where(id => id > 0 && id != senderId) + .Distinct() + .ToList(); + + if (uniqueRecipientIds.Count == 0) + { + return; + } + + var title = "Project assigned"; + var titleMy = "စီမံကိန်းတာဝန် ပေးအပ်ခြင်း"; + var body = $"You have been assigned to project '{project.Name}'"; + var bodyMy = $"သင့်အား စီမံကိန်း '{GetProjectNameMy(project)}' တွင် တာဝန်ပေးအပ်ထားပါသည်"; + + await SendAsync( + uniqueRecipientIds, + senderId, + NotificationType.ProjectUpdated, + "project", + project.Id, + title, + body, + titleMy, + bodyMy); + } + + 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 titleMy = "မှတ်ချက်အသစ် ရေးသားခြင်း"; + var actorNameMy = await GetUserFullNameMyAsync(senderId) ?? actorName; + var body = $"{actorName} commented on task '{task.Title}'"; + var bodyMy = $"{actorNameMy} သည် လုပ်ငန်း '{GetTaskTitleMy(task)}' တွင် မှတ်ချက်တစ်ခု ရေးသားခဲ့ပါသည်"; + + await SendAsync( + recipientIds, + senderId, + NotificationType.CommentAdded, + "task", + task.Id, + title, + body, + titleMy, + bodyMy); + } + + 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 titleMy = "သင့်ကို ရည်ညွှန်းဖော်ပြခြင်း"; + var actorNameMy = await GetUserFullNameMyAsync(senderId) ?? actorName; + var body = $"{actorName} mentioned you in task '{task.Title}'"; + var bodyMy = $"{actorNameMy} သည် လုပ်ငန်း '{GetTaskTitleMy(task)}' တွင် သင့်ကို ရည်ညွှန်းဖော်ပြခဲ့ပါသည်"; + + await SendAsync( + recipientIds, + senderId, + NotificationType.Mention, + "task", + task.Id, + title, + body, + titleMy, + bodyMy); + } + + private List<long> BuildTaskAudience(Database.AppDbContextModels.Task task, long senderId) + { + var audience = new HashSet<long>(); + + 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<List<long>> 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<long>(); + } + + var projectMemberIds = await _db.ProjectMembers + .Where(pm => pm.ProjectId == projectId) + .Select(pm => pm.UserId) + .ToListAsync(); + + if (projectMemberIds.Count == 0) + { + return new List<long>(); + } + + 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<long> recipientIds, + long senderId, + NotificationType notificationType, + string sourceType, + long sourceId, + string title, + string body, + string? titleMy = null, + string? bodyMy = null) + { + 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, + TitleMy = titleMy, + Body = body, + BodyMy = bodyMy, + SourceType = sourceType, + SourceId = sourceId, + IsRead = false, + CreatedAt = DateTime.UtcNow + }).ToList(); + + _db.Notifications.AddRange(notifications); + await _db.SaveChangesAsync(); + + var sender = senderId > 0 + ? await _db.Users + .Where(u => u.Id == senderId) + .Select(u => new + { + Name = $"{u.FirstName} {u.LastName}", + NameMy = $"{u.FirstNameMy} {u.LastNameMy}" + }) + .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, + TitleMy = notification.TitleMy, + Body = notification.Body, + BodyMy = notification.BodyMy, + 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 = sender?.Name, + SenderNameMy = string.IsNullOrWhiteSpace(sender?.NameMy) ? null : sender.NameMy.Trim() + }; + + 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<string, string> + { + ["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 static string GetTaskTitleMy(Database.AppDbContextModels.Task task) => + string.IsNullOrWhiteSpace(task.TitleMy) ? task.Title : task.TitleMy; + + private static string GetProjectNameMy(Database.AppDbContextModels.Project project) => + string.IsNullOrWhiteSpace(project.NameMy) ? project.Name : project.NameMy; + + private async Task<string?> GetUserFullNameMyAsync(long userId) + { + if (userId <= 0) + { + return null; + } + + var user = await _db.Users + .Where(u => u.Id == userId) + .Select(u => new + { + English = $"{u.FirstName} {u.LastName}", + Burmese = $"{u.FirstNameMy} {u.LastNameMy}" + }) + .FirstOrDefaultAsync(); + + if (user is null) + { + return null; + } + + return string.IsNullOrWhiteSpace(user.Burmese) + ? user.English.Trim() + : user.Burmese.Trim(); + } + + private async global::System.Threading.Tasks.Task<string> 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<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + new KeyValuePair<string, string>("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<FirebaseServiceAccount> LoadServiceAccountAsync() + { + var json = await File.ReadAllTextAsync(_serviceAccountPath); + var serviceAccount = JsonSerializer.Deserialize<FirebaseServiceAccount>(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<NotificationCleanupHostedService> _logger; + + public NotificationCleanupHostedService(IServiceScopeFactory scopeFactory, ILogger<NotificationCleanupHostedService> 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<AppDbContext>(); + 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<ActionResult<IEnumerable<TaskTrackingSystem.Shared.Models.Notification.NotificationDto>>> 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<ActionResult<int>> 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<ActionResult<Result>> 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<ActionResult<Result<int>>> 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<int>.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<NotificationHub> _hubContext; + + public NotificationRealtimeService(IHubContext<NotificationHub> 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..1235e308120dcd44b7fb73d076030531de546231 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs @@ -0,0 +1,188 @@ +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<IReadOnlyList<NotificationDto>> 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, + TitleMy = n.TitleMy, + Body = n.Body, + BodyMy = n.BodyMy, + 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}", + SenderNameMy = sender == null ? null : $"{sender.FirstNameMy} {sender.LastNameMy}" + }) + .Take(take) + .ToListAsync(); + + await EnrichLocalizedTextAsync(items); + + foreach (var item in items) + { + item.TargetUrl = NotificationNavigation.BuildTargetUrl(item.SourceType, item.SourceId, item.NotificationType); + } + + return items; + } + + private async global::System.Threading.Tasks.Task EnrichLocalizedTextAsync(List<NotificationDto> items) + { + if (items.Count == 0) + { + return; + } + + var taskIds = items + .Where(item => item.SourceType.Equals("task", StringComparison.OrdinalIgnoreCase)) + .Select(item => item.SourceId) + .Distinct() + .ToList(); + var projectIds = items + .Where(item => item.SourceType.Equals("project", StringComparison.OrdinalIgnoreCase)) + .Select(item => item.SourceId) + .Distinct() + .ToList(); + + var tasks = taskIds.Count == 0 + ? new Dictionary<long, (string Title, string? TitleMy)>() + : await _db.Tasks + .Where(task => taskIds.Contains(task.Id)) + .Select(task => new { task.Id, task.Title, task.TitleMy }) + .ToDictionaryAsync(task => task.Id, task => (task.Title, task.TitleMy)); + + var projects = projectIds.Count == 0 + ? new Dictionary<long, (string Name, string? NameMy)>() + : await _db.Projects + .Where(project => projectIds.Contains(project.Id)) + .Select(project => new { project.Id, project.Name, project.NameMy }) + .ToDictionaryAsync(project => project.Id, project => (project.Name, project.NameMy)); + + foreach (var item in items) + { + item.TitleMy = string.IsNullOrWhiteSpace(item.TitleMy) + ? GetTitleMy((NotificationType)item.NotificationType) + : item.TitleMy; + + if (!string.IsNullOrWhiteSpace(item.BodyMy)) + { + continue; + } + + if (item.SourceType.Equals("task", StringComparison.OrdinalIgnoreCase) + && tasks.TryGetValue(item.SourceId, out var task)) + { + var taskTitle = string.IsNullOrWhiteSpace(task.TitleMy) ? task.Title : task.TitleMy; + item.BodyMy = (NotificationType)item.NotificationType switch + { + NotificationType.TaskAssigned => $"သင့်အား လုပ်ငန်းသစ်တစ်ခု တာဝန်ပေးအပ်ထားပါသည် - {taskTitle}", + NotificationType.CommentAdded => $"{GetSenderNameMy(item)} သည် လုပ်ငန်း '{taskTitle}' တွင် မှတ်ချက်တစ်ခု ရေးသားခဲ့ပါသည်", + NotificationType.Mention => $"{GetSenderNameMy(item)} သည် လုပ်ငန်း '{taskTitle}' တွင် သင့်ကို ရည်ညွှန်းဖော်ပြခဲ့ပါသည်", + _ => item.BodyMy + }; + } + + if (item.SourceType.Equals("project", StringComparison.OrdinalIgnoreCase) + && projects.TryGetValue(item.SourceId, out var project) + && (NotificationType)item.NotificationType == NotificationType.ProjectUpdated) + { + var projectName = string.IsNullOrWhiteSpace(project.NameMy) ? project.Name : project.NameMy; + item.BodyMy = $"သင့်အား စီမံကိန်း '{projectName}' တွင် တာဝန်ပေးအပ်ထားပါသည်"; + } + } + } + + private static string? GetTitleMy(NotificationType notificationType) => notificationType switch + { + NotificationType.TaskAssigned => "လုပ်ငန်းတာဝန် ပေးအပ်ခြင်း", + NotificationType.StatusChanged => "လုပ်ငန်းအခြေအနေ ပြောင်းလဲခြင်း", + NotificationType.ProjectUpdated => "စီမံကိန်းတာဝန် ပေးအပ်ခြင်း", + NotificationType.CommentAdded => "မှတ်ချက်အသစ် ရေးသားခြင်း", + NotificationType.Mention => "သင့်ကို ရည်ညွှန်းဖော်ပြခြင်း", + _ => null + }; + + private static string GetSenderNameMy(NotificationDto item) + { + if (!string.IsNullOrWhiteSpace(item.SenderNameMy)) + { + return item.SenderNameMy.Trim(); + } + + if (string.Equals(item.SenderName, "System Admin", StringComparison.OrdinalIgnoreCase)) + { + return "စနစ်အုပ်ချုပ်သူ"; + } + + return string.IsNullOrWhiteSpace(item.SenderName) ? "စနစ်" : item.SenderName.Trim(); + } + + public async Task<int> GetUnreadCountAsync(long userId) + { + return await _db.Notifications.CountAsync(n => n.RecipientId == userId && !n.IsRead); + } + + public async Task<Result> 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<int> 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<ProjectDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult> 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<ActionResult<ProjectDto>> 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<ActionResult<Result<ProjectDto>>> CreateProject([FromBody] CreateProjectDto createProjectDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Project", "Create")) + { + return StatusCode(403, Result<ProjectDto>.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<ActionResult<Result>> 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<ActionResult<Result>> 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<ActionResult<Result<IEnumerable<UserDto>>>> GetProjectMembers(long id) + { + var result = await _projectService.GetProjectMembersAsync(id, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpPost("{id}/members")] + public async Task<IActionResult> 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<IActionResult> 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<ActionResult<Result<IEnumerable<TaskDto>>>> 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..76c5498ae6c2d1eec51847fc3e75a76d722f5a60 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs @@ -0,0 +1,449 @@ +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<IEnumerable<ProjectDto>> GetAllProjectsAsync(long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + return await BuildAccessibleProjectQuery(isAdmin, currentUserId) + .OrderByDescending(p => p.CreatedAt ?? DateTime.MinValue) + .ThenByDescending(p => p.Id) + .Select(p => new ProjectDto + { + Id = p.Id, + Name = p.Name, + NameMy = p.NameMy, + Description = p.Description, + DescriptionMy = p.DescriptionMy, + StartDate = p.StartDate, + EndDate = p.EndDate, + CreatedById = p.CreatedById, + CreatedAt = p.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = p.BudgetedHours + }).ToListAsync(); + } + + public async Task<PagedResult<ProjectDto>> 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.NameMy != null && p.NameMy.ToLower().Contains(searchTerm)) || + (p.Description != null && p.Description.ToLower().Contains(searchTerm)) || + (p.DescriptionMy != null && p.DescriptionMy.ToLower().Contains(searchTerm))); + } + + return await query + .OrderByDescending(p => p.CreatedAt ?? DateTime.MinValue) + .ThenByDescending(p => p.Id) + .Select(p => new ProjectDto + { + Id = p.Id, + Name = p.Name, + NameMy = p.NameMy, + Description = p.Description, + DescriptionMy = p.DescriptionMy, + StartDate = p.StartDate, + EndDate = p.EndDate, + CreatedById = p.CreatedById, + CreatedAt = p.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = p.BudgetedHours + }) + .ToPagedResultAsync(page, pageSize); + } + + public async Task<ProjectDto?> 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, + NameMy = project.NameMy, + Description = project.Description, + DescriptionMy = project.DescriptionMy, + StartDate = project.StartDate, + EndDate = project.EndDate, + CreatedById = project.CreatedById, + CreatedAt = project.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = project.BudgetedHours + }; + } + + public async Task<Result<ProjectDto>> CreateProjectAsync(CreateProjectDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Name) && string.IsNullOrWhiteSpace(dto.NameMy)) + { + return Result<ProjectDto>.Failure(ResultMessages.ProjectNameRequired, 400); + } + + var project = new TaskTrackingSystem.Database.AppDbContextModels.Project + { + Name = dto.Name ?? string.Empty, + NameMy = dto.NameMy, + Description = dto.Description, + DescriptionMy = dto.DescriptionMy, + 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, + NameMy = project.NameMy, + Description = project.Description, + DescriptionMy = project.DescriptionMy, + StartDate = project.StartDate, + EndDate = project.EndDate, + CreatedById = project.CreatedById, + CreatedAt = project.CreatedAt ?? DateTime.UtcNow, + BudgetedHours = project.BudgetedHours + }; + + return Result<ProjectDto>.Success(resultDto, 201); + } + + public async Task<Result> UpdateProjectAsync(long id, UpdateProjectDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Name) && string.IsNullOrWhiteSpace(dto.NameMy)) + { + 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 ?? string.Empty; + project.NameMy = dto.NameMy; + project.Description = dto.Description; + project.DescriptionMy = dto.DescriptionMy; + 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<Result> 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) + .ToListAsync(); + foreach (var task in tasksToUpdate) + { + if (task.IsDeleted != true) + { + task.IsDeleted = true; + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = currentUserId; + } + } + + if (tasksToUpdate.Any()) + { + _db.Tasks.UpdateRange(tasksToUpdate); + } + + var taskIds = tasksToUpdate.Select(t => t.Id).ToList(); + if (taskIds.Any()) + { + var issuesToUpdate = await _db.Issues + .Where(i => taskIds.Contains(i.TaskId) && i.IsDeleted != true) + .ToListAsync(); + foreach (var issue in issuesToUpdate) + { + issue.IsDeleted = true; + issue.UpdatedAt = DateTime.UtcNow; + issue.UpdatedBy = currentUserId; + } + + if (issuesToUpdate.Any()) + { + _db.Issues.UpdateRange(issuesToUpdate); + } + } + + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Delete", "Project", $"Deleted project '{project.Name}'"); + return Result.Success(200); + } + + public async Task<Result<IEnumerable<UserDto>>> GetProjectMembersAsync(long projectId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(projectId, isAdmin, currentUserId)) + { + return Result<IEnumerable<UserDto>>.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, + FirstNameMy = u.FirstNameMy, + LastNameMy = u.LastNameMy, + Email = u.Email, + Phone = u.Phone, + RoleId = u.RoleId, + IsActive = u.IsActive, + CreatedAt = u.CreatedAt ?? DateTime.UtcNow + }) + .ToListAsync(); + + return Result<IEnumerable<UserDto>>.Success(members); + } + + public async Task<Result> 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<Result> 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<Result<IEnumerable<TaskDto>>> GetProjectTasksAsync(long projectId, long roleId, long currentUserId) + { + var isAdmin = await DataScopeAuthorization.IsAdminScopeAsync(_db, roleId); + if (!await IsProjectAccessibleAsync(projectId, isAdmin, currentUserId)) + { + return Result<IEnumerable<TaskDto>>.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<IEnumerable<TaskDto>>.Success(tasks); + } + + private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Project> 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<bool> 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..431e48a1d3ec6a8edd58bdec647aff73fbea8c57 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs @@ -0,0 +1,265 @@ +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<IssueDto>), StatusCodes.Status200OK)] + public async Task<ActionResult<Result<List<IssueDto>>>> GetIssuesReport() + { + var result = await _reportService.GetIssuesReportAsync(User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpGet("tasks")] + [ProducesResponseType(typeof(PagedResult<TaskReportDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult<PagedResult<TaskReportDto>>> GetTasksReport( + [FromQuery] DateTime? startDate, + [FromQuery] DateTime? endDate, + [FromQuery] string? status, + [FromQuery] string? search, + [FromQuery] int? projectId, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null) + { + (startDate, endDate) = NormalizeDateRange(startDate, endDate); + if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value) + { + return BadRequest(Result.Failure("End date cannot be earlier than start date.", 400)); + } + + if (paging == null || (!paging.Page.HasValue && !paging.Limit.HasValue)) + { + var full = await _reportService.GetTasksReportAsync( + startDate, endDate, status, search, 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, search, projectId, User.GetRoleId(), User.GetUserId(), page, limit, assignedToMe, assignedToMyTeam); + return Ok(result); + } + + [HttpGet("user-productivity")] + [ProducesResponseType(typeof(PagedResult<UserProductivityDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult<PagedResult<UserProductivityDto>>> 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<UserProductivityDto>()); + } + + 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<ActionResult<Result<IEnumerable<TaskStatusSummaryDto>>>> 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<IActionResult> 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<TeamProductivityReportDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult<PagedResult<TeamProductivityReportDto>>> 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<IActionResult> 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<OverdueCriticalTaskDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult<PagedResult<OverdueCriticalTaskDto>>> GetOverdueCritical( + [FromQuery] string? search, + [FromQuery] long? projectId, + [FromQuery] int? priorityId, + [FromQuery] string? delayType, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null, + [FromQuery] int? page = null, + [FromQuery] int? limit = null) + { + var requestedPage = page ?? paging?.Page; + var requestedLimit = limit ?? paging?.Limit; + + if (!requestedPage.HasValue && !requestedLimit.HasValue) + { + var full = await _reportService.GetOverdueCriticalTasksAsync( + search, projectId, User.GetRoleId(), User.GetUserId(), priorityId, delayType, assignedToMe, assignedToMyTeam); + return StatusCode(full.StatusCode, full); + } + + var normalizedPage = PaginationExtensions.NormalizePage(requestedPage); + var normalizedLimit = PaginationExtensions.NormalizePageSize(requestedLimit ?? 0); + var result = await _reportService.GetPagedOverdueCriticalTasksAsync( + search, projectId, User.GetRoleId(), User.GetUserId(), normalizedPage, normalizedLimit, priorityId, delayType, assignedToMe, assignedToMyTeam); + return Ok(result); + } + + [HttpGet("overdue-critical/excel")] + public async Task<IActionResult> 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<EmployeeProductivityReportDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult<PagedResult<EmployeeProductivityReportDto>>> GetEmployeeProductivity( + [FromQuery] string? search, + [FromQuery] DateTime? startDate, + [FromQuery] DateTime? endDate, + [FromQuery] string? status, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null) + { + (startDate, endDate) = NormalizeDateRange(startDate, endDate); + if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value) + { + return BadRequest(Result.Failure("End date cannot be earlier than start date.", 400)); + } + + 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<ProjectProgressReportDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult<PagedResult<ProjectProgressReportDto>>> GetProjectProgress( + [FromQuery] string? search, + [FromQuery] DateTime? startDate, + [FromQuery] DateTime? endDate, + [FromQuery] string? status, + [FromQuery] bool? assignedToMe, + [FromQuery] bool? assignedToMyTeam, + [FromQuery] PaginationQuery? paging = null) + { + (startDate, endDate) = NormalizeDateRange(startDate, endDate); + if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value) + { + return BadRequest(Result.Failure("End date cannot be earlier than start date.", 400)); + } + + 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); + } + + private static (DateTime? StartDate, DateTime? EndDate) NormalizeDateRange(DateTime? startDate, DateTime? endDate) + { + static DateTime? Normalize(DateTime? value) => value.HasValue + ? DateTime.SpecifyKind(value.Value.Date, DateTimeKind.Utc) + : null; + + return (Normalize(startDate), Normalize(endDate)); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs b/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs new file mode 100644 index 0000000000000000000000000000000000000000..9a4bcfd12e95e265b6db0ccdf7d7d46111e74791 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs @@ -0,0 +1,1130 @@ +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<AppTaskStatus, string> StatusMap = new() { { AppTaskStatus.Todo, "To Do" }, { AppTaskStatus.InProgress, "In Progress" }, { AppTaskStatus.Done, "Done" } }; + private static readonly Dictionary<TaskPriority, string> PriorityMap = new() { { TaskPriority.Low, "Low" }, { TaskPriority.Medium, "Medium" }, { TaskPriority.High, "High" } }; + + public ReportService(AppDbContext db) + { + _db = db; + } + + public async Task<Result<List<IssueDto>>> 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, + TaskTitleMy = i.Task.TitleMy, + ProjectId = i.Task.ProjectId, + ProjectName = i.Task.Project.Name, + ProjectNameMy = i.Task.Project.NameMy, + Title = i.Title, + TitleMy = i.TitleMy, + Description = i.Description, + DescriptionMy = i.DescriptionMy, + AssignedTo = i.AssignedTo, + AssignedToName = i.AssignedToNavigation != null + ? i.AssignedToNavigation.FirstName + " " + i.AssignedToNavigation.LastName + : null, + AssignedToNameMy = i.AssignedToNavigation != null + ? i.AssignedToNavigation.FirstNameMy + " " + i.AssignedToNavigation.LastNameMy + : null, + EstimatedHours = i.EstimatedHours, + ActualHours = i.ActualHours, + DelayReason = i.DelayReason, + DelayReasonMy = i.DelayReasonMy, + IsBlocked = i.IsBlocked, + BlockedBy = i.BlockedBy, + BlockedByMy = i.BlockedByMy, + 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<List<IssueDto>>.Success(list); + } + + private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Task> BuildAccessibleTaskQuery(bool isAdmin, bool isManager, long currentUserId) + { + return _db.Tasks.Where(t => t.IsDeleted != true && !t.IsArchived); + } + + private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Project> BuildAccessibleProjectQuery(bool isAdmin, long currentUserId) + { + return _db.Projects.Where(p => p.IsDeleted != true); + } + + private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.User> BuildAccessibleUserQuery(bool isAdmin, long currentUserId) + { + return _db.Users.Where(u => !u.IsDeleted); + } + + private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Issue> 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<TDestination> MapPagedResult<TSource, TDestination>( + PagedResult<TSource> source, + Func<TSource, TDestination> map) + { + return new PagedResult<TDestination> + { + Items = source.Items.Select(map).ToList(), + TotalCount = source.TotalCount, + Page = source.Page, + PageSize = source.PageSize, + TotalPages = source.TotalPages + }; + } + + private async Task<List<long>> 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<IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Task>> BuildTaskReportQueryAsync( + bool isAdmin, + bool isManager, + long currentUserId, + DateTime? startDate, + DateTime? endDate, + string? status, + string? search, + 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 (!string.IsNullOrWhiteSpace(search)) + { + var searchPattern = $"%{search.Trim()}%"; + query = query.Where(t => + EF.Functions.ILike(t.Title ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.TitleMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.Description ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.DescriptionMy ?? string.Empty, searchPattern) || + (t.Project != null && + (EF.Functions.ILike(t.Project.Name ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.Project.NameMy ?? string.Empty, searchPattern))) || + (t.AssignedToNavigation != null && + (EF.Functions.ILike(t.AssignedToNavigation.Username ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.AssignedToNavigation.FirstName ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.AssignedToNavigation.LastName ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.AssignedToNavigation.FirstNameMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(t.AssignedToNavigation.LastNameMy ?? string.Empty, searchPattern)))); + } + + 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<AppTaskStatus>(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<long>(); + 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, + TitleMy = t.TitleMy, + Description = t.Description, + DescriptionMy = t.DescriptionMy, + ProjectId = t.ProjectId, + ProjectName = t.Project?.Name ?? string.Empty, + ProjectNameMy = t.Project?.NameMy, + 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, + AssignedToUserMy = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstNameMy} {t.AssignedToNavigation.LastNameMy}" : null, + AssignedByUser = t.AssignedByNavigation != null ? $"{t.AssignedByNavigation.FirstName} {t.AssignedByNavigation.LastName}" : null, + AssignedByUserMy = t.AssignedByNavigation != null ? $"{t.AssignedByNavigation.FirstNameMy} {t.AssignedByNavigation.LastNameMy}" : null, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow, + LastActivity = t.UpdatedAt ?? t.CreatedAt ?? DateTime.UtcNow + }; + } + + private async System.Threading.Tasks.Task EnrichTaskReportsAsync(List<TaskReportDto> 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.BlockedIssues = taskIssues.Count(i => i.IsBlocked); + report.IssueSummary = taskIssues.Count == 0 + ? "No issues" + : $"{report.OpenIssues} open, {report.OverdueIssues} overdue, {report.BlockedIssues} 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<OverdueCriticalTaskDto> 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.DelayReasonMy, + i.IsBlocked, + i.BlockedBy, + i.BlockedByMy, + 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.DelayReasonMy = riskIssue?.DelayReasonMy; + report.BlockedBy = riskIssue?.BlockedBy ?? string.Empty; + report.BlockedByMy = riskIssue?.BlockedByMy; + report.EscalationLevel = riskIssue?.EscalationLevel ?? 0; + } + } + + public async Task<Result<IEnumerable<TaskReportDto>>> GetTasksReportAsync( + DateTime? startDate, DateTime? endDate, string? status, string? search, 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, search, 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<IEnumerable<TaskReportDto>>.Success(list); + } + + public async Task<PagedResult<TaskReportDto>> GetPagedTasksReportAsync( + DateTime? startDate, + DateTime? endDate, + string? status, + string? search, + 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, search, 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<Result<IEnumerable<UserProductivityDto>>> 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<UserProductivityDto>(); + 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(), + FullNameMy = $"{user.FirstNameMy} {user.LastNameMy}".Trim(), + TotalAssignedTasks = total, + CompletedTasksCount = done, + EfficiencyRatio = total > 0 ? Math.Round(((double)done / total) * 100, 2) : 0, + OnTimeDeliveryRate = onTimeDeliveryRate + }); + } + return Result<IEnumerable<UserProductivityDto>>.Success(list); + } + + public async Task<Result<IEnumerable<TaskStatusSummaryDto>>> 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.TitleMy?.ToLower().Contains(s) == true) || + (t.Project?.Name.ToLower().Contains(s) == true) || + (t.Project?.NameMy?.ToLower().Contains(s) == true) || + (t.AssignedToNavigation != null && + ($"{t.AssignedToNavigation.FirstName} {t.AssignedToNavigation.LastName} {t.AssignedToNavigation.FirstNameMy} {t.AssignedToNavigation.LastNameMy}").ToLower().Contains(s)) + ).ToList(); + } + + var list = tasks.Select(t => new TaskStatusSummaryDto + { + TaskId = t.Id, + Title = t.Title, + TitleMy = t.TitleMy, + ProjectName = t.Project?.Name ?? "-", + ProjectNameMy = t.Project?.NameMy, + 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, + AssignedToMy = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstNameMy} {t.AssignedToNavigation.LastNameMy}" : null, + DueDate = t.DueDate, + CreatedAt = t.CreatedAt ?? DateTime.UtcNow + }).ToList(); + + return Result<IEnumerable<TaskStatusSummaryDto>>.Success(list); + } + + public byte[] ExportTaskStatusSummaryToExcel(IEnumerable<TaskStatusSummaryDto> 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<PagedResult<TeamProductivityReportDto>> 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<TeamProductivityReportDto>(); + } + + 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<TeamProductivityReportDto> + { + Items = items, + TotalCount = totalCount, + Page = normalizedPage, + PageSize = normalizedPageSize, + TotalPages = totalPages + }; + } + + public async Task<Result<IEnumerable<TeamProductivityReportDto>>> 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(), + FullNameMy = $"{u.FirstNameMy} {u.LastNameMy}".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.FullNameMy?.ToLower().Contains(s) == true) || + u.Username.ToLower().Contains(s)).ToList(); + } + + return Result<IEnumerable<TeamProductivityReportDto>>.Success(list.OrderByDescending(u => u.CompletionRate)); + } + + public byte[] ExportTeamProductivityToExcel(IEnumerable<TeamProductivityReportDto> 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(); + } + + public async Task<Result<IEnumerable<OverdueCriticalTaskDto>>> 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<long>(); + 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.TitleMy?.ToLower().Contains(s) == true) || + (t.Project?.Name.ToLower().Contains(s) == true) || + (t.Project?.NameMy?.ToLower().Contains(s) == true) + ).ToList(); + } + + var list = tasks.Select(t => new OverdueCriticalTaskDto + { + TaskId = t.Id, + Title = t.Title, + TitleMy = t.TitleMy, + Description = t.Description, + DescriptionMy = t.DescriptionMy, + ProjectName = t.Project?.Name ?? "-", + ProjectNameMy = t.Project?.NameMy, + 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, + AssignedToMy = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstNameMy} {t.AssignedToNavigation.LastNameMy}" : 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<IEnumerable<OverdueCriticalTaskDto>>.Success(list); + } + + public async Task<PagedResult<OverdueCriticalTaskDto>> 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<long>(); + 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.TitleMy != null && t.TitleMy.ToLower().Contains(s)) || + (t.Description != null && t.Description.ToLower().Contains(s)) || + (t.DescriptionMy != null && t.DescriptionMy.ToLower().Contains(s)) || + (t.Project != null && (t.Project.Name.ToLower().Contains(s) || + (t.Project.NameMy != null && t.Project.NameMy.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, + TitleMy = t.TitleMy, + Description = t.Description, + DescriptionMy = t.DescriptionMy, + ProjectName = t.Project?.Name ?? "-", + ProjectNameMy = t.Project?.NameMy, + 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, + AssignedToMy = t.AssignedToNavigation != null ? $"{t.AssignedToNavigation.FirstNameMy} {t.AssignedToNavigation.LastNameMy}" : 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<OverdueCriticalTaskDto> 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<Result<IEnumerable<EmployeeProductivityReportDto>>> 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<long>(); + 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<AppTaskStatus>(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<EmployeeProductivityReportDto>(); + 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(), + FullNameMy = $"{user.FirstNameMy} {user.LastNameMy}".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.FullNameMy?.ToLower().Contains(s) == true) || + u.Username.ToLower().Contains(s)).ToList(); + } + + return Result<IEnumerable<EmployeeProductivityReportDto>>.Success(list.OrderByDescending(u => u.OpenIssues).ThenByDescending(u => u.ActualHours)); + } + + public async Task<PagedResult<EmployeeProductivityReportDto>> 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<EmployeeProductivityReportDto>(); + } + + 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<EmployeeProductivityReportDto> + { + Items = items, + TotalCount = totalCount, + Page = normalizedPage, + PageSize = normalizedPageSize, + TotalPages = totalPages + }; + } + + public async Task<Result<IEnumerable<ProjectProgressReportDto>>> 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<ProjectProgressReportDto>(); + 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<AppTaskStatus>(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, + ProjectNameMy = project.NameMy, + 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) || + (p.ProjectNameMy?.ToLower().Contains(s) == true)).ToList(); + } + + return Result<IEnumerable<ProjectProgressReportDto>>.Success(list.OrderBy(p => p.ProjectName)); + } + + public async Task<PagedResult<ProjectProgressReportDto>> 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<ProjectProgressReportDto>(); + } + + 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<ProjectProgressReportDto> + { + 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<RoleDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult> 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<ActionResult<RoleDto>> 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<ActionResult<Result<RoleDto>>> CreateRole([FromBody] CreateRoleDto createRoleDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Create")) + { + return StatusCode(403, Result<RoleDto>.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<ActionResult<Result>> 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<ActionResult<Result>> 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<ActionResult<Result<List<string>>>> GetAssignedAccessCodes(long id) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Role", "Update")) + { + return StatusCode(403, Result<List<string>>.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<IActionResult> 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..86c6f83f2a0564afd4932e6e833140925294321f --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs @@ -0,0 +1,538 @@ +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<IEnumerable<RoleDto>> GetAllRolesAsync() + { + return await _db.Roles + .Where(r => r.IsDeleted != true) + .Select(r => new RoleDto + { + Id = r.Id, + Name = r.Name, + NameMy = r.NameMy, + Description = r.Description, + DescriptionMy = r.DescriptionMy, + CreatedAt = r.CreatedAt ?? DateTime.UtcNow + }) + .ToListAsync(); + } + + public async Task<PagedResult<RoleDto>> 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.NameMy != null && r.NameMy.ToLower().Contains(searchTerm)) || + (r.Description != null && r.Description.ToLower().Contains(searchTerm)) || + (r.DescriptionMy != null && r.DescriptionMy.ToLower().Contains(searchTerm))); + } + + return await query + .OrderBy(r => r.Name) + .Select(r => new RoleDto + { + Id = r.Id, + Name = r.Name, + NameMy = r.NameMy, + Description = r.Description, + DescriptionMy = r.DescriptionMy, + CreatedAt = r.CreatedAt ?? DateTime.UtcNow + }) + .ToPagedResultAsync(page, pageSize); + } + + public async Task<RoleDto?> 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, + NameMy = role.NameMy, + Description = role.Description, + DescriptionMy = role.DescriptionMy, + CreatedAt = role.CreatedAt ?? DateTime.UtcNow + }; + } + + public async Task<Result<RoleDto>> CreateRoleAsync(CreateRoleDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Name) && string.IsNullOrWhiteSpace(dto.NameMy)) + { + return Result<RoleDto>.Failure(ResultMessages.RoleNameRequired, 400); + } + + var nameExists = !string.IsNullOrWhiteSpace(dto.Name) && await _db.Roles.AnyAsync(r => r.Name == dto.Name); + if (nameExists) + { + return Result<RoleDto>.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<RoleDto>.Failure(validCodesResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, validCodesResult.StatusCode); + } + } + + var role = new RoleEntity + { + Name = dto.Name ?? string.Empty, + NameMy = dto.NameMy, + Description = dto.Description, + DescriptionMy = dto.DescriptionMy, + 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<string>() }, + currentUserId); + if (!assignResult.IsSuccess) + { + return Result<RoleDto>.Failure(assignResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, assignResult.StatusCode); + } + + return Result<RoleDto>.Success(new RoleDto + { + Id = role.Id, + Name = role.Name, + NameMy = role.NameMy, + Description = role.Description, + DescriptionMy = role.DescriptionMy, + CreatedAt = role.CreatedAt ?? DateTime.UtcNow + }, 201); + } + + public async Task<Result> UpdateRoleAsync(long id, UpdateRoleDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Name) && string.IsNullOrWhiteSpace(dto.NameMy)) + { + 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 = !string.IsNullOrWhiteSpace(dto.Name) && 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 ?? string.Empty; + role.NameMy = dto.NameMy; + role.Description = dto.Description; + role.DescriptionMy = dto.DescriptionMy; + 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<Result> 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<Result<List<string>>> GetAssignedAccessCodesByRoleIdAsync(long roleId) + { + var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true); + if (role == null) + { + return Result<List<string>>.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<List<string>>.Success(assignedCodes); + } + + public async Task<Result> 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<long>(); + 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<long>(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<long, long?> parentLookup, ISet<long> 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<Result> ValidateAccessCodesAsync(IEnumerable<string> 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<TaskDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult> 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<ActionResult<IEnumerable<TaskDto>>> GetArchivedTasks() + { + var tasks = await _taskService.GetArchivedTasksAsync(User.GetRoleId(), User.GetUserId()); + return Ok(tasks); + } + + [HttpGet("{id}")] + public async Task<ActionResult<TaskDto>> 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<ActionResult<Result<TaskDto>>> CreateTask([FromBody] CreateTaskDto createTaskDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Task", "Create")) + { + return StatusCode(403, Result<TaskDto>.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<ActionResult<Result>> 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<ActionResult<Result>> 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<ActionResult<Result<IEnumerable<TaskDto>>>> GetUserTasks(long userId) + { + var result = await _taskService.GetTasksByUserIdAsync(userId, User.GetRoleId(), User.GetUserId()); + return StatusCode(result.StatusCode, result); + } + + [HttpPatch("{id}/status")] + public async Task<IActionResult> 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<ActionResult<Result<int>>> ArchiveDoneTasks([FromQuery] long? projectId) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/Task", "Update")) + { + return StatusCode(403, Result<int>.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..9439120d271f98d54120f26288d05ee054f68c75 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs @@ -0,0 +1,151 @@ +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; + } + + [HttpGet("tasks/{taskId}/comments")] + public async Task<ActionResult<IEnumerable<CommentDto>>> 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}", + UserFullNameMy = $"{c.User.FirstNameMy} {c.User.LastNameMy}", + UserRoleName = c.User.Role != null ? c.User.Role.Name : "User", + UserRoleNameMy = c.User.Role != null ? c.User.Role.NameMy : null, + Message = c.Message, + MessageMy = c.MessageMy, + CreatedAt = c.CreatedAt ?? DateTime.UtcNow, + UpdatedAt = c.UpdatedAt + }) + .ToListAsync(); + + return Ok(comments); + } + + [HttpPost("tasks/{taskId}/comments")] + public async Task<ActionResult<Result<CommentDto>>> CreateComment(long taskId, [FromBody] CreateCommentDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Message) && string.IsNullOrWhiteSpace(dto.MessageMy)) + { + return BadRequest(Result<CommentDto>.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<CommentDto>.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() ?? string.Empty, + MessageMy = dto.MessageMy, + 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}", + UserFullNameMy = $"{user.FirstNameMy} {user.LastNameMy}", + UserRoleName = user.Role != null ? user.Role.Name : "User", + UserRoleNameMy = user.Role != null ? user.Role.NameMy : null, + Message = comment.Message, + MessageMy = comment.MessageMy, + CreatedAt = comment.CreatedAt ?? DateTime.UtcNow + }; + + return StatusCode(201, Result<CommentDto>.Success(resultDto, 201)); + } + + [HttpDelete("comments/{commentId}")] + public async Task<ActionResult<Result>> 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..a52100b7fb133369d2106fcf8ca5e7958775b91e --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs @@ -0,0 +1,494 @@ +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<IEnumerable<TaskDto>> GetAllTasksAsync(long roleId, long currentUserId) + { + var query = await BuildAccessibleTaskQueryAsync(roleId, currentUserId); + return await query + .OrderByDescending(t => t.CreatedAt ?? DateTime.MinValue) + .ThenByDescending(t => t.Id) + .Select(t => new TaskDto + { + Id = t.Id, + Title = t.Title, + TitleMy = t.TitleMy, + Description = t.Description, + DescriptionMy = t.DescriptionMy, + 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<PagedResult<TaskDto>> 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.TitleMy != null && t.TitleMy.ToLower().Contains(searchTerm)) || + (t.Description != null && t.Description.ToLower().Contains(searchTerm)) || + (t.DescriptionMy != null && t.DescriptionMy.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 + .OrderByDescending(t => t.CreatedAt ?? DateTime.MinValue) + .ThenByDescending(t => t.Id) + .Select(t => new TaskDto + { + Id = t.Id, + Title = t.Title, + TitleMy = t.TitleMy, + Description = t.Description, + DescriptionMy = t.DescriptionMy, + 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<IEnumerable<TaskDto>> 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, + TitleMy = t.TitleMy, + Description = t.Description, + DescriptionMy = t.DescriptionMy, + 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<TaskDto?> 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, + TitleMy = task.TitleMy, + Description = task.Description, + DescriptionMy = task.DescriptionMy, + 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<Result<TaskDto>> CreateTaskAsync(CreateTaskDto dto, long roleId, long currentUserId) + { + if (string.IsNullOrWhiteSpace(dto.Title) && string.IsNullOrWhiteSpace(dto.TitleMy)) + { + return Result<TaskDto>.Failure(ResultMessages.TaskTitleRequired, 400); + } + + if (dto.ProjectId == 0) + { + return Result<TaskDto>.Failure(ResultMessages.SelectProjectRequired, 400); + } + + var projectExists = await _db.Projects.AnyAsync(p => p.Id == dto.ProjectId && p.IsDeleted != true); + if (!projectExists) + { + return Result<TaskDto>.Failure(ResultMessages.ProjectNotFound(dto.ProjectId), 404); + } + + if (!await CanAccessProjectAsync(dto.ProjectId, roleId, currentUserId)) + { + return Result<TaskDto>.Failure("You do not have access to this project.", 403); + } + + if (dto.AssignedTo.HasValue && !await IsProjectMemberAsync(dto.ProjectId, dto.AssignedTo.Value)) + { + return Result<TaskDto>.Failure("Task assignee must belong to the selected project.", 400); + } + + var task = new TaskTrackingSystem.Database.AppDbContextModels.Task + { + Title = dto.Title ?? string.Empty, + TitleMy = dto.TitleMy, + Description = dto.Description, + DescriptionMy = dto.DescriptionMy, + 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, + TitleMy = task.TitleMy, + Description = task.Description, + DescriptionMy = task.DescriptionMy, + 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<TaskDto>.Success(resultDto, 201); + } + + public async Task<Result> UpdateTaskAsync(long id, UpdateTaskDto dto, long? currentUserId = null) + { + if (string.IsNullOrWhiteSpace(dto.Title) && string.IsNullOrWhiteSpace(dto.TitleMy)) + { + 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 ?? string.Empty; + task.TitleMy = dto.TitleMy; + task.Description = dto.Description; + task.DescriptionMy = dto.DescriptionMy; + 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<Result> 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); + + var issuesToUpdate = await _db.Issues + .Where(i => i.TaskId == id && i.IsDeleted != true) + .ToListAsync(); + foreach (var issue in issuesToUpdate) + { + issue.IsDeleted = true; + issue.UpdatedAt = DateTime.UtcNow; + issue.UpdatedBy = currentUserId; + } + + if (issuesToUpdate.Any()) + { + _db.Issues.UpdateRange(issuesToUpdate); + } + + await _db.SaveChangesAsync(); + await _auditLog.LogAsync("Delete", "Task", $"Deleted task '{task.Title}'"); + return Result.Success(200); + } + + public async Task<Result<IEnumerable<TaskDto>>> GetTasksByUserIdAsync(long userId, long roleId, long currentUserId) + { + var userExists = await _db.Users.AnyAsync(u => u.Id == userId && !u.IsDeleted); + if (!userExists) + { + return Result<IEnumerable<TaskDto>>.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, + TitleMy = t.TitleMy, + Description = t.Description, + DescriptionMy = t.DescriptionMy, + 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<IEnumerable<TaskDto>>.Success(tasks); + } + + public async Task<Result> 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<Result<int>> 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<int>.Failure(ResultMessages.ProjectNotFound(projectId.Value), 404); + } + + if (!await CanAccessProjectAsync(projectId.Value, roleId, currentUserId)) + { + return Result<int>.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<int>.Success(tasksToArchive.Count); + } + + private async Task<IQueryable<TaskTrackingSystem.Database.AppDbContextModels.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<bool> 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<bool> IsProjectMemberAsync(long projectId, long userId) + { + return await _db.ProjectMembers.AnyAsync(pm => pm.ProjectId == projectId && pm.UserId == userId); + } + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Translation/ConfiguredHttpContentTranslationService.cs b/TaskTrackingSystem.WebApi/Features/Translation/ConfiguredHttpContentTranslationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..b6549aeb97b072420a7d11ebad24bd8cc9f52f8b --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Translation/ConfiguredHttpContentTranslationService.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using TaskTrackingSystem.Shared.Localization; + +namespace TaskTrackingSystem.WebApi.Features.Translation; + +/// <summary> +/// Optional server-side provider adapter. It is inert until Translation:Endpoint is configured. +/// </summary> +public sealed class ConfiguredHttpContentTranslationService : IContentTranslationService +{ + private readonly IHttpClientFactory httpClientFactory; + private readonly IConfiguration configuration; + private readonly ILogger<ConfiguredHttpContentTranslationService> logger; + + public ConfiguredHttpContentTranslationService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger<ConfiguredHttpContentTranslationService> logger) + { + this.httpClientFactory = httpClientFactory; + this.configuration = configuration; + this.logger = logger; + } + + public Task<TranslationResult> TranslateAsync(string sourceText, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => + SendAsync(sourceText, sourceLanguage, targetLanguage, false, cancellationToken); + + public Task<TranslationResult> TransliterateNameAsync(string sourceName, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => + SendAsync(sourceName, sourceLanguage, targetLanguage, true, cancellationToken); + + private async Task<TranslationResult> SendAsync(string sourceText, string sourceLanguage, string targetLanguage, bool isName, CancellationToken cancellationToken) + { + var endpoint = configuration["Translation:Endpoint"] ?? Environment.GetEnvironmentVariable("TRANSLATION_API_URL"); + if (string.IsNullOrWhiteSpace(endpoint)) return TranslationResult.NotConfigured(); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + var key = configuration["Translation:ApiKey"] ?? Environment.GetEnvironmentVariable("TRANSLATION_API_KEY"); + if (!string.IsNullOrWhiteSpace(key)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key); + request.Content = JsonContent.Create(new + { + text = sourceText, + sourceLanguage, + targetLanguage, + mode = isName ? "transliteration" : "translation", + instruction = isName + ? "Transliterate pronunciation/spelling only. Do not translate semantic meaning. Return the name only." + : "Translate to natural formal Burmese or English. Preserve names unless requested, codes, IDs, dates, numbers, placeholders, URLs, and appropriate technical terms. Return translated text only." + }); + + var client = httpClientFactory.CreateClient(); + using var response = await client.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("Translation provider returned HTTP {StatusCode}.", (int)response.StatusCode); + return new(false, null, $"Translation provider returned {(int)response.StatusCode}.", ProviderName(), false); + } + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var root = document.RootElement; + var text = TryGetString(root, "translatedText") ?? TryGetString(root, "translation") ?? TryGetString(root, "text"); + return string.IsNullOrWhiteSpace(text) + ? new(false, null, "Translation provider returned no translated text.", ProviderName(), false) + : TranslationResult.Generated(text.Trim(), ProviderName()); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning("Translation provider request failed: {Message}", ex.Message); + return new(false, null, "Translation provider request failed.", ProviderName(), false); + } + } + + private string ProviderName() => configuration["Translation:Provider"] ?? Environment.GetEnvironmentVariable("TRANSLATION_PROVIDER") ?? "configured-http"; + + private static string? TryGetString(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; +} diff --git a/TaskTrackingSystem.WebApi/Features/Translation/OpenRouterContentTranslationService.cs b/TaskTrackingSystem.WebApi/Features/Translation/OpenRouterContentTranslationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..20d73b40213393485967b0199e6b0daa9331f4d1 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Translation/OpenRouterContentTranslationService.cs @@ -0,0 +1,257 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TaskTrackingSystem.Shared.Localization; + +namespace TaskTrackingSystem.WebApi.Features.Translation; + +/// <summary> +/// Server-only OpenRouter Chat Completions provider. Credentials never leave the API process. +/// </summary> +public sealed class OpenRouterContentTranslationService : IContentTranslationService +{ + private const string Provider = "OpenRouter"; + private const string DefaultModel = "openrouter/free"; + private readonly IHttpClientFactory httpClientFactory; + private readonly IConfiguration configuration; + private readonly ILogger<OpenRouterContentTranslationService> logger; + + public OpenRouterContentTranslationService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger<OpenRouterContentTranslationService> logger) + { + this.httpClientFactory = httpClientFactory; + this.configuration = configuration; + this.logger = logger; + } + + public Task<TranslationResult> TranslateAsync( + string sourceText, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken = default) => + GenerateAsync(sourceText, sourceLanguage, targetLanguage, isName: false, cancellationToken); + + public Task<TranslationResult> TransliterateNameAsync( + string sourceName, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken = default) => + GenerateAsync(sourceName, sourceLanguage, targetLanguage, isName: true, cancellationToken); + + private async Task<TranslationResult> GenerateAsync( + string sourceText, + string sourceLanguage, + string targetLanguage, + bool isName, + CancellationToken cancellationToken) + { + var endpoint = configuration["Translation:Endpoint"]; + var apiKey = configuration["Translation:ApiKey"]; + var model = configuration["Translation:Model"] ?? DefaultModel; + if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey)) + { + return NotConfigured(); + } + + var systemPrompt = isName + ? "You are a professional name transliterator. Return ONLY the transliterated name." + : "You are a professional translator. Return ONLY the translated text."; + var userPrompt = isName + ? $"Transliterate the personal name by pronunciation from {sourceLanguage} to {targetLanguage}: {sourceText}" + : $"Translate from {sourceLanguage} to {targetLanguage}: {sourceText}"; + var requestBody = new OpenRouterChatCompletionRequest + { + Model = model, + Messages = + [ + new OpenRouterMessage { Role = "system", Content = systemPrompt }, + new OpenRouterMessage { Role = "user", Content = userPrompt } + ], + Temperature = 0.2 + }; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Headers.TryAddWithoutValidation("HTTP-Referer", "https://huggingface.co"); + request.Headers.TryAddWithoutValidation("X-Title", "TaskTrackingSystem"); + request.Content = JsonContent.Create(requestBody, options: OpenRouterJson.Options); + + var client = httpClientFactory.CreateClient(); + using var response = await client.SendAsync(request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + return FailureForHttpStatus(response.StatusCode, responseBody); + } + + OpenRouterChatCompletionResponse? parsed; + try + { + parsed = JsonSerializer.Deserialize<OpenRouterChatCompletionResponse>(responseBody, OpenRouterJson.Options); + } + catch (JsonException) + { + return Failure("OpenRouter returned invalid JSON."); + } + + var translatedText = parsed?.Choices?.FirstOrDefault()?.Message?.Content; + if (string.IsNullOrWhiteSpace(translatedText)) + { + return Failure("OpenRouter returned empty translated text."); + } + + var cleanedText = CleanTranslatedText(translatedText); + if (string.IsNullOrWhiteSpace(cleanedText)) + { + return Failure("OpenRouter returned empty translated text."); + } + + if (string.Equals(cleanedText, userPrompt, StringComparison.Ordinal) || + string.Equals(cleanedText, systemPrompt, StringComparison.Ordinal)) + { + return Failure("OpenRouter returned the prompt instead of a translation."); + } + + return TranslationResult.Generated(cleanedText, Provider); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (TaskCanceledException) + { + return Failure("OpenRouter translation request timed out."); + } + catch (HttpRequestException) + { + return Failure("OpenRouter translation request failed."); + } + catch (JsonException) + { + return Failure("OpenRouter returned invalid JSON."); + } + catch (Exception ex) + { + logger.LogWarning("OpenRouter translation request failed with {ExceptionType}.", ex.GetType().Name); + return Failure("OpenRouter translation request failed."); + } + } + + private TranslationResult FailureForHttpStatus(HttpStatusCode statusCode, string responseBody) + { + var providerMessage = TryReadErrorMessage(responseBody); + var message = statusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => $"OpenRouter rejected the API key (HTTP {(int)statusCode}).", + (HttpStatusCode)429 => $"OpenRouter rate limit exceeded (HTTP {(int)statusCode}). Please try again later.", + _ => $"OpenRouter returned HTTP {(int)statusCode}." + }; + + if (!string.IsNullOrWhiteSpace(providerMessage)) + { + message = $"{message} {providerMessage}"; + } + + logger.LogWarning("OpenRouter returned HTTP {StatusCode}.", (int)statusCode); + return Failure(message); + } + + private static string? TryReadErrorMessage(string responseBody) + { + try + { + var error = JsonSerializer.Deserialize<OpenRouterErrorEnvelope>(responseBody, OpenRouterJson.Options)?.Error; + return string.IsNullOrWhiteSpace(error?.Message) ? null : error.Message.Trim(); + } + catch (JsonException) + { + return null; + } + } + + private static string CleanTranslatedText(string text) + { + var cleaned = text.Trim(); + if (cleaned.StartsWith("```") && cleaned.EndsWith("```")) + { + cleaned = cleaned[3..^3].Trim(); + } + + while (cleaned.Length >= 2 && + ((cleaned[0] == '"' && cleaned[^1] == '"') || + (cleaned[0] == '“' && cleaned[^1] == '”') || + (cleaned[0] == '\'' && cleaned[^1] == '\''))) + { + cleaned = cleaned[1..^1].Trim(); + } + + return cleaned; + } + + private static TranslationResult NotConfigured() => + new(false, null, "Translation provider is not configured.", Provider, false); + + private static TranslationResult Failure(string message) => + new(false, null, message, Provider, false); +} + +internal static class OpenRouterJson +{ + internal static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; +} + +internal sealed class OpenRouterChatCompletionRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("messages")] + public List<OpenRouterMessage> Messages { get; set; } = []; + + [JsonPropertyName("temperature")] + public double Temperature { get; set; } +} + +internal sealed class OpenRouterMessage +{ + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +internal sealed class OpenRouterChatCompletionResponse +{ + [JsonPropertyName("choices")] + public List<OpenRouterChoice>? Choices { get; set; } +} + +internal sealed class OpenRouterChoice +{ + [JsonPropertyName("message")] + public OpenRouterMessage? Message { get; set; } +} + +internal sealed class OpenRouterErrorEnvelope +{ + [JsonPropertyName("error")] + public OpenRouterError? Error { get; set; } +} + +internal sealed class OpenRouterError +{ + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/TaskTrackingSystem.WebApi/Features/Translation/TranslationController.cs b/TaskTrackingSystem.WebApi/Features/Translation/TranslationController.cs new file mode 100644 index 0000000000000000000000000000000000000000..fb3b3c3c23d895b8a9c180d692c89f14a0779018 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Translation/TranslationController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaskTrackingSystem.Shared.Localization; + +namespace TaskTrackingSystem.WebApi.Features.Translation; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class TranslationController : ControllerBase +{ + private readonly IContentTranslationService translationService; + + public TranslationController(IContentTranslationService translationService) + { + this.translationService = translationService; + } + + [HttpPost("generate")] + public async Task<ActionResult<TranslationResult>> Generate( + [FromBody] TranslationRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Text)) + { + return BadRequest(new TranslationResult(false, null, "Text is required.", "none", false)); + } + + var result = request.IsName + ? await translationService.TransliterateNameAsync(request.Text, request.SourceLanguage, request.TargetLanguage, cancellationToken) + : await translationService.TranslateAsync(request.Text, request.SourceLanguage, request.TargetLanguage, cancellationToken); + + return Ok(result); + } +} 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<UserDto>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task<ActionResult> 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); + } + + /// <summary>Returns the user IDs of all colleagues who share any project with the current user.</summary> + [HttpGet("my-team-user-ids")] + public async Task<ActionResult<List<long>>> GetMyTeamUserIds() + { + var currentUserId = User.GetUserId(); + var myProjectIds = await _userService.GetMyProjectIdsAsync(currentUserId); + var teamUserIds = await _userService.GetTeamUserIdsAsync(myProjectIds, currentUserId); + return Ok(teamUserIds); + } + + /// <summary>Returns all project IDs the current user is a member of.</summary> + [HttpGet("my-project-ids")] + public async Task<ActionResult<List<long>>> GetMyProjectIds() + { + var currentUserId = User.GetUserId(); + var projectIds = await _userService.GetMyProjectIdsAsync(currentUserId); + return Ok(projectIds); + } + + [HttpGet("{id}")] + public async Task<ActionResult<UserDto>> 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<ActionResult<Result<UserDto>>> CreateUser([FromBody] CreateUserDto createUserDto) + { + if (!await _permissionAuthorizationService.CanAccessAsync(User, "api/User", "Create")) + { + return StatusCode(403, Result<UserDto>.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<ActionResult<Result>> 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<ActionResult<Result>> 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..07a816ca5aab242a5bc66679e79954e960b72e97 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/User/UserService.cs @@ -0,0 +1,269 @@ +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<TaskTrackingSystem.Database.AppDbContextModels.User> _passwordHasher; + private readonly Infrastructure.AuditLogService _auditLog; + + public UserService(AppDbContext db, IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User> passwordHasher, Infrastructure.AuditLogService auditLog) + { + _db = db; + _passwordHasher = passwordHasher; + _auditLog = auditLog; + } + + public async Task<IEnumerable<UserDto>> 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, + FirstNameMy = u.FirstNameMy, + LastNameMy = u.LastNameMy, + Email = u.Email, + Phone = u.Phone, + RoleId = u.RoleId, + IsActive = u.IsActive, + CreatedAt = u.CreatedAt ?? DateTime.UtcNow + }).ToListAsync(); + } + + public async Task<PagedResult<UserDto>> 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(); + var searchPattern = $"%{searchTerm}%"; + query = query.Where(u => + EF.Functions.ILike(u.FirstName ?? string.Empty, searchPattern) || + EF.Functions.ILike(u.LastName ?? string.Empty, searchPattern) || + EF.Functions.ILike((u.FirstName ?? string.Empty) + " " + (u.LastName ?? string.Empty), searchPattern) || + EF.Functions.ILike((u.FirstName ?? string.Empty) + (u.LastName ?? string.Empty), searchPattern) || + EF.Functions.ILike(u.FirstNameMy ?? string.Empty, searchPattern) || + EF.Functions.ILike(u.LastNameMy ?? string.Empty, searchPattern) || + EF.Functions.ILike((u.FirstNameMy ?? string.Empty) + " " + (u.LastNameMy ?? string.Empty), searchPattern) || + EF.Functions.ILike((u.FirstNameMy ?? string.Empty) + (u.LastNameMy ?? string.Empty), searchPattern) || + EF.Functions.ILike(u.Username ?? string.Empty, searchPattern) || + EF.Functions.ILike(u.Email ?? string.Empty, searchPattern)); + } + + 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, + FirstNameMy = u.FirstNameMy, + LastNameMy = u.LastNameMy, + Email = u.Email, + Phone = u.Phone, + RoleId = u.RoleId, + IsActive = u.IsActive, + CreatedAt = u.CreatedAt ?? DateTime.UtcNow + }) + .ToPagedResultAsync(page, pageSize); + } + + public async Task<UserDto?> 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, + FirstNameMy = user.FirstNameMy, + LastNameMy = user.LastNameMy, + Email = user.Email, + Phone = user.Phone, + RoleId = user.RoleId, + IsActive = user.IsActive, + CreatedAt = user.CreatedAt ?? DateTime.UtcNow + }; + } + + public async Task<Result<UserDto>> CreateUserAsync(CreateUserDto dto, long? currentUserId = null) + { + var englishNameComplete = !string.IsNullOrWhiteSpace(dto.FirstName) && !string.IsNullOrWhiteSpace(dto.LastName); + var burmeseNameComplete = !string.IsNullOrWhiteSpace(dto.FirstNameMy) && !string.IsNullOrWhiteSpace(dto.LastNameMy); + if (string.IsNullOrWhiteSpace(dto.Username) || string.IsNullOrWhiteSpace(dto.Email) || (!englishNameComplete && !burmeseNameComplete)) + { + return Result<UserDto>.Failure(ResultMessages.FillAllFields, 400); + } + + var usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username && !u.IsDeleted); + if (usernameExists) + { + return Result<UserDto>.Failure("Username is already taken.", 400); + } + + var emailExists = await _db.Users.AnyAsync(u => u.Email == dto.Email && !u.IsDeleted); + if (emailExists) + { + return Result<UserDto>.Failure("Email is already registered.", 400); + } + + var user = new TaskTrackingSystem.Database.AppDbContextModels.User + { + Username = dto.Username, + FirstName = dto.FirstName ?? string.Empty, + LastName = dto.LastName ?? string.Empty, + FirstNameMy = dto.FirstNameMy, + LastNameMy = dto.LastNameMy, + 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, + FirstNameMy = user.FirstNameMy, + LastNameMy = user.LastNameMy, + Email = user.Email, + Phone = user.Phone, + RoleId = user.RoleId, + IsActive = user.IsActive, + CreatedAt = user.CreatedAt ?? DateTime.UtcNow + }; + + return Result<UserDto>.Success(resultDto, 201); + } + + public async Task<Result> UpdateUserAsync(long id, UpdateUserDto dto, long? currentUserId = null) + { + var englishNameComplete = !string.IsNullOrWhiteSpace(dto.FirstName) && !string.IsNullOrWhiteSpace(dto.LastName); + var burmeseNameComplete = !string.IsNullOrWhiteSpace(dto.FirstNameMy) && !string.IsNullOrWhiteSpace(dto.LastNameMy); + if (string.IsNullOrWhiteSpace(dto.Username) || (!englishNameComplete && !burmeseNameComplete)) + { + 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 ?? string.Empty; + user.LastName = dto.LastName ?? string.Empty; + user.FirstNameMy = dto.FirstNameMy; + user.LastNameMy = dto.LastNameMy; + 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<Result> 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<List<long>> GetMyProjectIdsAsync(long currentUserId) + { + return await _db.ProjectMembers + .Where(pm => pm.UserId == currentUserId) + .Select(pm => pm.ProjectId) + .Distinct() + .ToListAsync(); + } + + public async Task<List<long>> GetTeamUserIdsAsync(List<long> projectIds, long currentUserId) + { + if (projectIds == null || projectIds.Count == 0) + return new List<long>(); + + 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<Result> 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<ActionResult<Result>> 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<bool> 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<bool> 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<PagedResult<T>> ToPagedResultAsync<T>( + this IQueryable<T> 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<T> + { + 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<bool> 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<long> 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..b09ce8eb941b39ffeb8c8617e02b728b82b22c73 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Program.cs @@ -0,0 +1,584 @@ +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.Net.Sockets; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Localization; + +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<TaskTrackingSystem.WebApi.Infrastructure.AuditLogService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.User.UserService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Auth.AuthService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Role.RoleService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Project.ProjectService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Task.TaskService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Issue.IssueService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Dashboard.DashboardService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Report.ReportService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Menu.MenuService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.UserDevice.UserDeviceService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.NotificationRealtimeService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Notification.NotificationService>(); +if (string.Equals(builder.Configuration["Translation:Provider"], "OpenRouter", StringComparison.OrdinalIgnoreCase)) +{ + builder.Services.AddScoped<IContentTranslationService, TaskTrackingSystem.WebApi.Features.Translation.OpenRouterContentTranslationService>(); +} +else +{ + builder.Services.AddScoped<IContentTranslationService, NoOpContentTranslationService>(); +} +builder.Services.AddHostedService<TaskTrackingSystem.WebApi.Features.Notification.NotificationCleanupHostedService>(); +builder.Services.AddScoped<TaskTrackingSystem.WebApi.Infrastructure.PermissionAuthorizationService>(); +builder.Services.AddScoped<IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>, PasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>>(); +// 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<string>() + } + }); +}); +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<AppDbContext>(opt => +{ + opt.UseNpgsql( + builder.Configuration.GetConnectionString("DefaultConnection"), + npgsql => npgsql.EnableRetryOnFailure(5, TimeSpan.FromSeconds(2), null)); +}); + +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<TaskTrackingSystem.WebApi.Features.Notification.NotificationHub>("/hubs/notifications"); + +app.Run(); + +static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); + + await EnsureDatabaseCreatedWithRetryAsync(db); + // 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<IDatabaseCreator>() 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 EnsureDatabaseCreatedWithRetryAsync(AppDbContext db) +{ + const int maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + await db.Database.EnsureCreatedAsync(); + return; + } + catch (Exception ex) when (attempt < maxAttempts && IsTransientDatabaseStartupException(ex)) + { + Console.WriteLine($"Database startup connection failed (attempt {attempt}/{maxAttempts}). Retrying in 2 seconds. {ex.GetBaseException().Message}"); + await System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(2)); + } + } +} + +static bool IsTransientDatabaseStartupException(Exception exception) +{ + for (var current = exception; current != null; current = current.InnerException) + { + if (current is SocketException) + { + return true; + } + } + + return false; +} + +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..92d09b8f9a3814e53cfa77e1a424190c6d7cbf49 --- /dev/null +++ b/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj @@ -0,0 +1,47 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <UserSecretsId>TaskTrackingSystem-WebApi</UserSecretsId> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <UseAppHost>false</UseAppHost> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="ClosedXML" Version="0.102.3" /> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.27" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.16"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\TaskTrackingSystem.Database\TaskTrackingSystem.Database.csproj" /> + <ProjectReference Include="..\TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="Firebase\ttsfirebasekey.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <ItemGroup> + <Folder Include="Features\Dashboard\" /> + <Folder Include="Features\Report\" /> + <Folder Include="Features\Task\" /> + <Folder Include="Features\Project\" /> + <Folder Include="Features\Role\" /> + </ItemGroup> + + <ItemGroup> + <Compile Remove="artifacts\**" /> + <EmbeddedResource Remove="artifacts\**" /> + <None Remove="artifacts\**" /> + </ItemGroup> + +</Project> 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..920c006a77cd4df9c5ab29f41caf93545c8aa66b --- /dev/null +++ b/TaskTrackingSystem.WebApi/appsettings.json @@ -0,0 +1,29 @@ +{ + "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" + }, + "Translation": { + "Provider": "OpenRouter", + "Endpoint": "https://openrouter.ai/api/v1/chat/completions", + "Model": "openrouter/free", + "ApiKey": "" + }, + "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<IResult> 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<Result<AuthResponseDto>>(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<IResult> 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<Result<AuthResponseDto>>(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<AuthResponseDto> + try + { + var result = JsonSerializer.Deserialize<Result<AuthResponseDto>>(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<string>(); + 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<IResult> 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<Result>(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<Result>(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<IResult> 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<Claim> + { + 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<Dictionary<string, JsonElement>>(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<string> 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<Claim> + { + 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<TaskTrackingSystem.Shared.Models.Menu.MenuDto> 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; + +/// <summary> +/// Scoped service that creates authorized HttpClient instances for the WebApi. +/// Inject this instead of IHttpClientFactory in Blazor components. +/// </summary> +public class ApiClientService +{ + private readonly IHttpClientFactory _factory; + private readonly UserSessionState _sessionState; + private readonly IHttpContextAccessor _httpContextAccessor; + + /// <summary>The base URL of the WebApi (without trailing /api/). Used for direct file download links.</summary> + 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..aa4c7ac7881248d766c40b22b30bf150b23eabd8 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/App.razor @@ -0,0 +1,646 @@ +<!DOCTYPE html> +<html lang="my"> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <base href="/" /> + <link rel="icon" type="image/png" href="favicon.png" /> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"> + <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script> + <script src="firebase-app-compat.js"></script> + <script src="firebase-messaging-compat.js"></script> + <script src="firebase-config.js"></script> + <script src="firebase-messaging.js"></script> + <script> + (() => { + const storageKey = 'tts-theme'; + let theme = 'light'; + try { + const storedTheme = localStorage.getItem(storageKey); + theme = storedTheme === 'dark' || storedTheme === 'light' ? storedTheme : 'light'; + localStorage.setItem(storageKey, theme); + } catch { + } + document.documentElement.classList.toggle('dark', theme === 'dark'); + document.documentElement.dataset.theme = theme; + })(); + </script> + <script> + (() => { + const storageKey = 'tts-language'; + let language = 'my'; + try { + language = localStorage.getItem(storageKey) === 'en' ? 'en' : 'my'; + } catch { + } + document.documentElement.lang = language; + })(); + </script> + <script> + window.languagePreference = { + normalize(code) { + return code === 'en' ? 'en' : 'my'; + }, + apply(storageKey, code) { + const normalized = this.normalize(code); + document.documentElement.lang = normalized; + try { + localStorage.setItem(storageKey, normalized); + } catch { + } + return normalized; + }, + init(storageKey) { + let code = 'my'; + try { + code = this.normalize(localStorage.getItem(storageKey)); + } catch { + } + return this.apply(storageKey, code); + }, + toggle(storageKey) { + const next = document.documentElement.lang === 'my' ? 'en' : 'my'; + return this.apply(storageKey, next); + }, + set(storageKey, code) { + return this.apply(storageKey, code); + } + }; + </script> + <script> + const brandDark = "#12312D"; + const brandDarkAlt = "#115E59"; + const brandPrimary = "#0F766E"; + const brandPrimaryHover = "#115E59"; + const brandAccent = "#B45309"; + const brandLight = "#E6F4F1"; + const brandMuted = "#64706D"; + + const semanticRedBg = "#FCE4E1"; + const semanticRedText = "#B42318"; + + const semanticGreenBg = "#DFF6EE"; + const semanticGreenText = "#0F766E"; + + const semanticNeutralBg = "#F1F5F4"; + const semanticNeutralText = "#4F5D59"; + + const neutralBg = "#F7F8F7"; + const neutralBorder = "#DDE5E2"; + const neutralMuted = "#64706D"; + const neutralDark = "#17211F"; + + const customGray = { + 50: neutralBg, + 100: "#EFF3F1", + 200: neutralBorder, + 300: "#B9C8C3", + 400: "#8B9894", + 500: neutralMuted, + 600: "#4F5D59", + 700: "#34413D", + 800: "#222C29", + 900: neutralDark, + 950: "#101614" + }; + + tailwind.config = { + darkMode: 'class', + theme: { + colors: { + transparent: 'transparent', + current: 'currentColor', + white: '#ffffff', + black: '#000000', + + slate: customGray, + gray: customGray, + zinc: customGray, + neutral: customGray, + stone: customGray, + + red: { + 50: semanticRedBg, + 100: semanticRedBg, + 200: "#FECDD3", + 300: "#FDA4AF", + 400: "#FB7185", + 500: semanticRedText, + 600: semanticRedText, + 700: semanticRedText, + 800: semanticRedText, + 900: semanticRedText + }, + rose: { + 50: semanticRedBg, + 100: semanticRedBg, + 200: "#FECDD3", + 300: "#FDA4AF", + 400: "#FB7185", + 500: semanticRedText, + 600: semanticRedText, + 700: semanticRedText, + 800: semanticRedText, + 900: semanticRedText + }, + green: { + 50: semanticGreenBg, + 100: semanticGreenBg, + 200: "#A7F3D0", + 300: "#6EE7B7", + 400: "#34D399", + 500: semanticGreenText, + 600: semanticGreenText, + 700: semanticGreenText, + 800: semanticGreenText, + 900: semanticGreenText + }, + emerald: { + 50: semanticGreenBg, + 100: semanticGreenBg, + 200: "#A7F3D0", + 300: "#6EE7B7", + 400: "#34D399", + 500: semanticGreenText, + 600: semanticGreenText, + 700: semanticGreenText, + 800: semanticGreenText, + 900: semanticGreenText + }, + teal: { + 50: "#F0FDFA", + 100: "#CCFBF1", + 200: "#99F6E4", + 300: "#5EEAD4", + 400: "#2DD4BF", + 500: "#14B8A6", + 600: "#0D9488", + 700: "#0F766E", + 800: "#115E59", + 900: "#134E4A" + }, + cyan: { + 50: "#ECFEFF", + 100: "#CFFAFE", + 200: "#A5F3FC", + 300: "#67E8F9", + 400: "#22D3EE", + 500: "#06B6D4", + 600: "#0891B2", + 700: "#0E7490", + 800: "#155E75", + 900: "#164E63" + }, + sky: { + 50: "#F0F9FF", + 100: "#E0F2FE", + 200: "#BAE6FD", + 300: "#7DD3FC", + 400: "#38BDF8", + 500: "#0EA5E9", + 600: "#0284C7", + 700: "#0369A1", + 800: "#075985", + 900: "#0C4A6E" + }, + blue: { + 50: brandLight, + 100: "#CFE8E3", + 200: "#A7D8CF", + 300: "#76C3B8", + 400: "#3AA89A", + 500: "#16877E", + 600: brandPrimary, + 700: brandPrimaryHover, + 800: brandDarkAlt, + 900: brandDark + }, + amber: { + 50: "#FEF3C7", + 100: "#FDE68A", + 200: "#FCD34D", + 300: "#FBBF24", + 400: "#F59E0B", + 500: "#B45309", + 600: "#92400E", + 700: "#78350F", + 800: "#78350F", + 900: "#78350F" + }, + yellow: { + 50: "#FEFCE8", + 100: "#FEF3C7", + 200: "#FDE68A", + 300: "#FCD34D", + 400: "#F59E0B", + 500: "#B45309", + 600: "#92400E", + 700: "#78350F", + 800: "#78350F", + 900: "#78350F" + }, + violet: { + 50: brandLight, + 100: "#CFE8E3", + 200: "#A7D8CF", + 300: "#76C3B8", + 400: "#3AA89A", + 500: "#16877E", + 600: brandPrimary, + 700: brandPrimaryHover, + 800: brandDark, + 900: brandDark, + 950: brandDark + }, + purple: { + 50: brandLight, + 100: "#CFE8E3", + 200: "#A7D8CF", + 300: "#76C3B8", + 400: "#3AA89A", + 500: "#16877E", + 600: brandPrimary, + 700: brandPrimaryHover, + 800: brandDark, + 900: brandDark, + 950: brandDark + }, + indigo: { + 50: brandLight, + 100: "#CFE8E3", + 200: "#A7D8CF", + 300: "#76C3B8", + 400: "#3AA89A", + 500: "#16877E", + 600: brandPrimary, + 700: brandPrimaryHover, + 800: brandDark, + 900: brandDark, + 950: brandDark + }, + + chart: { + purple: brandPrimary, + teal: "#14B8A6", + green: "#0F766E", + neutral: neutralBorder + }, + + background: neutralBg, + foreground: neutralDark, + card: "#FFFFFF", + "card-foreground": neutralDark, + popover: "#FFFFFF", + "popover-foreground": neutralDark, + + primary: brandPrimary, + "primary-foreground": "#FFFFFF", + + secondary: brandLight, + "secondary-foreground": neutralDark, + + muted: "#F3F4F6", + "muted-foreground": neutralMuted, + + accent: brandAccent, + "accent-foreground": "#FFFFFF", + + border: neutralBorder, + }, + borderRadius: { + none: "0", + sm: "0.375rem", + DEFAULT: "0.5rem", + md: "0.5rem", + lg: "0.5rem", + xl: "0.5rem", + "2xl": "0.5rem", + "3xl": "0.5rem", + full: "9999px" + } + } + } + </script> + <script src="https://cdn.tailwindcss.com"></script> + <link rel="stylesheet" href="TaskTrackingSystem.WebApp.styles.css" /> + <link rel="stylesheet" href="app.css?v=20260724-4" /> + <script src="theme-toggle.js?v=20260720-3" defer></script> + <script src="https://code.highcharts.com/highcharts.js"></script> + <script src="https://code.highcharts.com/gantt/modules/gantt.js"></script> + <script src="https://unpkg.com/lucide@latest"></script> + <HeadOutlet /> +</head> + +<body> + <Routes /> + <script src="_framework/blazor.web.js"></script> + <script> + window.convertDigitsToBurmese = function(strOrNum) { + if (strOrNum === null || strOrNum === undefined) return ''; + const str = strOrNum.toString(); + const map = { + '0': '၀', '1': '၁', '2': '၂', '3': '၃', '4': '၄', + '5': '၅', '6': '၆', '7': '၇', '8': '၈', '9': '၉' + }; + return str.replace(/[0-9]/g, function(w) { return map[w]; }); + }; + + window.renderPieChart = function (containerId, data) { + const container = document.getElementById(containerId); + if (!container) return; + Highcharts.chart(containerId, { + chart: { + type: 'pie', + backgroundColor: 'transparent', + height: 260, + margin: [0, 0, 0, 0], + spacing: [0, 0, 0, 0] + }, + title: { + text: null + }, + tooltip: { + backgroundColor: 'rgba(255, 255, 255, 0.97)', + borderWidth: 1, + borderColor: '#dde5e2', + borderRadius: 8, + shadow: true, + style: { + fontFamily: 'Inter, sans-serif', + fontSize: '13px', + color: '#17211f' + }, + pointFormat: '<b>{point.name}</b>: <b>{point.y}</b> tasks ({point.percentage:.1f}%)' + }, + plotOptions: { + pie: { + borderWidth: 0, + borderColor: 'transparent', + allowPointSelect: true, + cursor: 'pointer', + dataLabels: { + enabled: true, + distance: -30, + format: '{point.percentage:.0f}%', + style: { + color: 'white', + textOutline: 'none', + fontFamily: 'Inter, sans-serif', + fontSize: '12px', + fontWeight: 'bold' + }, + filter: { + property: 'percentage', + operator: '>', + value: 5 + } + }, + showInLegend: false + } + }, + series: [{ + name: 'Tasks', + colorByPoint: true, + data: data + }], + credits: { + enabled: false + } + }); + }; + + window.renderGanttChart = function (containerId, data, options) { + options = options || {}; + const container = document.getElementById(containerId); + if (!container) return; + const minWidth = options.minWidth || 1200; + const chartHeight = Math.max(options.height || 420, 120 + (data.length * 44)); + + if (options.isBurmese) { + Highcharts.setOptions({ + lang: { + months: ["ဇန်နဝါရီ", "ဖေဖော်ဝါရီ", "မတ်", "ဧပြီ", "မေ", "ဇွန်", "ဇူလိုင်", "သြဂုတ်", "စက်တင်ဘာ", "အောက်တိုဘာ", "နိုဝင်ဘာ", "ဒီဇင်ဘာ"], + shortMonths: ["ဇန်", "ဖေ", "မတ်", "ဧပြီ", "မေ", "ဇွန်", "ဇူလိုင်", "သြဂုတ်", "စက်", "အောက်", "နို", "ဒီ"], + weekdays: ["တနင်္ဂနွေ", "တနင်္လာ", "အင်္ဂါ", "ဗုဒ္ဓဟူး", "ကြာသပတေး", "သောကြာ", "စနေ"] + } + }); + } else { + Highcharts.setOptions({ + lang: { + months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], + shortMonths: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + weekdays: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + } + }); + } + + Highcharts.ganttChart(containerId, { + chart: { + backgroundColor: 'transparent', + height: chartHeight, + style: { + fontFamily: 'Inter, sans-serif' + }, + scrollablePlotArea: { + minWidth: minWidth, + scrollPositionX: 0 + } + }, + title: { + text: null + }, + credits: { + enabled: false + }, + navigator: { + enabled: false + }, + scrollbar: { + enabled: true + }, + rangeSelector: { + enabled: false + }, + legend: { + enabled: false + }, + tooltip: { + backgroundColor: 'rgba(255, 255, 255, 0.97)', + borderWidth: 1, + borderColor: '#dde5e2', + borderRadius: 8, + shadow: true, + style: { + fontSize: '13px', + color: '#17211f' + }, + pointFormatter: function() { + const start = Highcharts.dateFormat('%b %e, %Y', this.start); + const end = Highcharts.dateFormat('%b %e, %Y', this.end); + const progress = this.completed && this.completed.amount !== undefined + ? Math.round(this.completed.amount * 100) + '%' + : '0%'; + if (options.isBurmese) { + const startStr = window.convertDigitsToBurmese(start); + const endStr = window.convertDigitsToBurmese(end); + const progressStr = window.convertDigitsToBurmese(progress); + return `<b>${this.name}</b><br/>စတင်ရက်: ${startStr}<br/>ပြီးဆုံးရက်: ${endStr}<br/>တိုးတက်မှု: ${progressStr}`; + } + return `<b>${this.name}</b><br/>Start: ${start}<br/>End: ${end}<br/>Progress: ${progress}`; + } + }, + xAxis: [{ + type: 'datetime', + min: options.xAxisMin, + max: options.xAxisMax, + gridLineWidth: 1, + gridLineColor: '#eff3f1', + lineColor: '#dde5e2', + tickColor: '#dde5e2', + labels: { + formatter: function() { + const date = new Date(this.value); + const monthNames = options.isBurmese + ? ["ဇန်", "ဖေ", "မတ်", "ဧပြီ", "မေ", "ဇွန်", "ဇူလိုင်", "သြဂုတ်", "စက်", "အောက်", "နို", "ဒီ"] + : ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const formatted = monthNames[date.getUTCMonth()] + ' ' + date.getUTCFullYear(); + return options.isBurmese ? window.convertDigitsToBurmese(formatted) : formatted; + }, + style: { + color: '#64706d', + fontSize: '11px' + } + }, + currentDateIndicator: { + color: '#0f766e', + dashStyle: 'ShortDash', + width: 2, + label: { + format: options.isBurmese ? 'ယနေ့' : 'Today', + style: { + color: '#0f766e', + fontWeight: 'bold' + } + } + } + }], + yAxis: { + gridLineWidth: 1, + gridLineColor: '#eff3f1', + labels: { + style: { + color: '#34413d', + fontSize: '12px', + fontWeight: '600' + } + } + }, + plotOptions: { + gantt: { + borderWidth: 0, + borderColor: 'transparent', + dataLabels: { + enabled: true, + formatter: function() { + if (this.point.completed && this.point.completed.amount !== undefined) { + const percent = Math.round(this.point.completed.amount * 100) + '%'; + return options.isBurmese ? window.convertDigitsToBurmese(percent) : percent; + } + return options.isBurmese ? '၀%' : '0%'; + }, + style: { + cursor: 'default', + pointerEvents: 'none', + textOutline: 'none', + color: '#ffffff', + fontSize: '11px', + fontWeight: 'bold' + } + } + } + }, + series: [{ + name: 'Projects', + borderWidth: 0, + borderColor: 'transparent', + data: data.map(item => ({ + id: item.id, + name: item.name, + start: item.start, + end: item.end, + completed: { + amount: item.completed, + fill: item.color + }, + color: '#dde5e2' + })) + }] + }); + }; + + function initIcons() { + if (window.lucide) { + window.lucide.createIcons(); + } + if (typeof initFlatpickr === 'function') { + initFlatpickr(); + } + } + function downloadFile(filename, contentType, content) { + const file = new Blob([content], {type: contentType}); + const exportUrl = URL.createObjectURL(file); + const a = document.createElement("a"); + a.href = exportUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(exportUrl); + } + function downloadFileFromBase64(filename, contentType, base64Content) { + const byteCharacters = atob(base64Content); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const file = new Blob([byteArray], {type: contentType}); + const exportUrl = URL.createObjectURL(file); + const a = document.createElement("a"); + a.href = exportUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(exportUrl); + } + function initFlatpickr() { + if (window.flatpickr) { + flatpickr("input[type='date']", { + altInput: true, + altFormat: "d-m-Y", + dateFormat: "Y-m-d", + allowInput: true + }); + } + } + function refreshClientEnhancements() { + if (window.updateTaskifyThemeControls) { + window.updateTaskifyThemeControls(); + } + initIcons(); + initFlatpickr(); + } + // Run once loaded + window.addEventListener('DOMContentLoaded', () => { + refreshClientEnhancements(); + }); + // Blazor enhanced page updates trigger + Blazor.addEventListener('enhancedload', () => { + refreshClientEnhancements(); + }); + </script> +</body> + +</html> 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<AuthenticationState> 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..812ea9dbb6fa36672e265956a286095e3c910dd0 --- /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 + +<div class="app-shell flex min-h-screen bg-slate-50 text-slate-950 dark:bg-slate-950 dark:text-slate-50"> + <button type="button" + class="sidebar-scrim" + onclick="document.body.classList.remove('tts-sidebar-open');" + aria-label="@AppLocalization.Text("common.closeNavigation", "Close navigation")"></button> + + <aside class="app-sidebar sticky top-0 z-40 flex h-screen w-64 shrink-0 flex-col overflow-hidden border-r"> + <div class="app-sidebar-brand flex min-h-20 items-center gap-3 px-5 py-4"> + <span class="app-sidebar-logo inline-flex h-10 w-10 items-center justify-center rounded-lg text-white shadow-sm"> + <i data-lucide="clipboard-check" class="h-5 w-5"></i> + </span> + <span class="app-sidebar-title flex flex-col leading-tight"> + <strong class="text-lg font-bold tracking-normal">Taskify</strong> + <span class="mt-0.5 text-[11px] font-semibold uppercase tracking-wide">@AppLocalization.Text("common.workspace", "Workspace")</span> + </span> + <button type="button" + class="control-button mobile-sidebar-close ml-auto hidden h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white hover:bg-white/15 hover:text-white lg:hidden" + onclick="document.body.classList.remove('tts-sidebar-open');" + aria-label="@AppLocalization.Text("common.closeNavigation", "Close navigation")"> + <i data-lucide="x" class="h-4 w-4"></i> + </button> + </div> + + <div class="flex-1 overflow-y-auto pb-28"> + <NavMenu @rendermode="new InteractiveServerRenderMode(prerender: false)" /> + </div> + + <div class="app-sidebar-user absolute inset-x-0 bottom-0 z-20 shrink-0 border-t p-3 backdrop-blur"> + <AuthorizeView> + <Authorized> + <div class="app-sidebar-user-card flex items-center rounded-lg border p-2 transition-colors"> + <span class="app-sidebar-avatar flex h-10 w-10 items-center justify-center rounded-full font-semibold text-sm shrink-0"> + @(context.User.Identity?.Name?.Length > 0 ? context.User.Identity.Name[0].ToString().ToUpper() : "U") + </span> + <div class="ml-3 flex min-w-0 flex-col overflow-hidden"> + <span class="app-sidebar-username max-w-[130px] truncate text-sm font-semibold">@context.User.Identity?.Name</span> + <form id="logout-form" method="post" action="/account/logout" class="mt-0.5"> + <AntiforgeryToken /> + <button type="button" onclick="document.body.classList.remove('tts-sidebar-open'); document.getElementById('logout-confirm-dialog').style.display='flex';" class="app-sidebar-signout text-xs hover:opacity-90 font-semibold text-left flex items-center gap-1 transition-all"> + <i data-lucide="log-out" class="h-3 w-3"></i> + @Language.Texts.SignOut + </button> + </form> + </div> + </div> + </Authorized> + <NotAuthorized> + <a href="/login" class="app-sidebar-user-card flex items-center justify-center rounded-lg px-4 py-2.5 text-sm font-medium transition-colors"> + @Language.Texts.SignIn + </a> + </NotAuthorized> + </AuthorizeView> + </div> + </aside> + + <main class="app-main flex min-w-0 flex-1 flex-col lg:ml-4"> + <header class="app-topbar flex min-h-16 items-center justify-between gap-3 border-b border-slate-200 bg-slate-50/95 px-4 text-slate-900 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90 dark:text-slate-100"> + <div class="flex min-w-0 items-center gap-3"> + <button type="button" + class="control-button mobile-sidebar-toggle hidden h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 shadow-sm hover:bg-slate-50 hover:text-teal-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white" + onclick="document.body.classList.add('tts-sidebar-open');" + aria-label="@AppLocalization.Text("common.openNavigation", "Open navigation")"> + <i data-lucide="menu" class="h-4 w-4"></i> + </button> + <div class="app-topbar-title min-w-0"> + <h1 class="truncate text-base font-bold tracking-normal text-slate-950 dark:text-slate-50">@CurrentPageTitle</h1> + <p class="hidden truncate text-xs font-medium text-slate-500 dark:text-slate-400 sm:block">@CurrentPageDescription</p> + </div> + </div> + <div class="flex shrink-0 items-center gap-2 sm:gap-3"> + <AuthorizeView> + <Authorized> + <FirebaseTokenSync /> + </Authorized> + </AuthorizeView> + <NotificationBell /> + <LanguageToggle /> + <ThemeToggle /> + </div> + </header> + + <div class="app-content flex-1 overflow-y-auto p-5 lg:p-6"> + @if (isAuthorizedPage) + { + @Body + } + else + { + <div class="flex flex-col items-center justify-center py-20 px-4"> + <div class="rounded-full p-4 mb-4" style="color: var(--brand-accent); background-color: var(--status-warning-bg);"> + <i data-lucide="shield-alert" class="h-12 w-12"></i> + </div> + <h3 class="text-xl font-bold" style="color: var(--text-primary);">@Language.Texts.AccessDenied</h3> + <p class="text-center mt-2 max-w-sm text-sm" style="color: var(--text-muted);"> + @Language.Texts.AccessDeniedDescription + </p> + <a href="@dashboardHref" class="mt-6 inline-flex items-center rounded-lg px-4 py-2.5 text-sm font-medium text-white transition-colors shadow-sm hover:opacity-90" style="background-color: var(--brand-primary);"> + <i data-lucide="arrow-left" class="h-4 w-4 mr-2"></i> + @Language.Texts.BackToDashboard + </a> + </div> + } + </div> + </main> + + <div id="logout-confirm-dialog" class="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/45 px-4 backdrop-blur-sm" style="display: none;"> + <div class="w-full max-w-sm overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg dark:border-slate-800 dark:bg-slate-900"> + <div class="p-6 text-center"> + <div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full" style="background-color: var(--status-warning-bg); color: var(--status-warning-text);"> + <i data-lucide="log-out" class="h-7 w-7"></i> + </div> + <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-50">@Language.Texts.SignOutTitle</h3> + <p class="mt-2 text-sm text-slate-500 dark:text-slate-400">@Language.Texts.SignOutDescription</p> + </div> + <div class="flex items-center justify-center space-x-3 border-t border-slate-100 bg-slate-50/50 px-6 py-4 dark:border-slate-800 dark:bg-slate-950/40"> + <button type="button" onclick="document.getElementById('logout-confirm-dialog').style.display='none';" class="flex-1 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"> + @Language.Texts.Cancel + </button> + <button type="button" onclick="document.getElementById('logout-form').submit();" class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium text-white transition-colors" style="background-color: var(--brand-accent);"> + @Language.Texts.ConfirmSignOut + </button> + </div> + </div> + </div> +</div> + +@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", "အလုပ်တာဝန် စီမံခန့်ခွဲမှုဘုတ်"); + } + + 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", "အလုပ်တာဝန် စီမံခန့်ခွဲမှုဘုတ်"), + "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", "အလုပ်တာဝန် စီမံခန့်ခွဲမှုဘုတ်") => 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<TaskTrackingSystem.Shared.Models.Menu.MenuDto> 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..fc39abca66341ee85b33169721272894f95f895d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor @@ -0,0 +1,280 @@ +@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 TaskTrackingSystem.WebApp.Localization.ILocalizedContentService Content +@inject AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization +@inject NavigationManager Navigation +@inject IJSRuntime JS + +<nav class="flex-1 space-y-1 overflow-y-auto px-3 pb-5 pt-2 text-slate-300" + data-enhance-nav="false" + onclick="if (event.target.closest('a')) document.body.classList.remove('tts-sidebar-open');"> + @if (menus.Any()) + { + @foreach (var menu in menus) + { + if (menu.SubMenus != null && menu.SubMenus.Count > 0) + { + <button class="nav-dropdown-toggle flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:bg-white/10 hover:text-white @(IsMenuGroupExpanded(menu.MenuCode) ? "expanded bg-white/10 text-white" : "")" + @onclick="() => ToggleMenuGroup(menu.MenuCode)"> + @RenderIcon(menu.Icon) + <span class="flex-1 text-left">@Content.GetMenuName(menu)</span> + <i data-lucide="chevron-down" class="nav-chevron h-4 w-4 text-slate-400 shrink-0"></i> + </button> + @if (IsMenuGroupExpanded(menu.MenuCode)) + { + <div class="nav-dropdown-children"> + @foreach (var sub in menu.SubMenus) + { + <NavLink class="nav-link-item nav-child-item flex items-center rounded-lg px-3 py-2 text-sm font-semibold text-slate-300 transition-colors hover:bg-white/10 hover:text-white" + href="@MenuAuthorizationService.NormalizeMenuHref(sub.MenuUrl)" + Match="@GetNavLinkMatch(sub.MenuUrl)"> + @RenderSmallIcon(sub.Icon) + <span>@Content.GetMenuName(sub)</span> + </NavLink> + } + </div> + } + } + else + { + <NavLink class="nav-link-item flex items-center rounded-lg px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:bg-white/10 hover:text-white" + href="@MenuAuthorizationService.NormalizeMenuHref(menu.MenuUrl)" + Match="@(string.IsNullOrWhiteSpace(menu.MenuUrl) || menu.MenuUrl.Trim('/') is "dashboard" or "" ? NavLinkMatch.All : NavLinkMatch.Prefix)"> + @RenderIcon(menu.Icon) + <span>@Content.GetMenuName(menu)</span> + </NavLink> + } + } + } + else + { + <div class="px-4 py-6 text-xs text-slate-400"> + @Language.Texts.NoAccessItems + </div> + } +</nav> + +@code { + private List<MenuDto> menus = new(); + private string currentPath = string.Empty; + private HashSet<string> 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<MenuDto> ProcessMenus(List<MenuDto> 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 => Content.GetMenuName(menu)) + .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<MenuDto> 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<MenuDto> menuList) + { + var toRemove = new List<MenuDto>(); + + 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..22592720c78cef431ad61c0bf481d833906848f1 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Audit/AuditLogs.razor @@ -0,0 +1,243 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("auditLogs", "Audit Logs")</PageTitle> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex items-center justify-between"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("auditLogs", "Audit Logs")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.PageDescription("auditLogs", "Review system activity and changes.")</p> + </div> + </div> + + <!-- Search Panel --> + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm mb-4"> + <div class="flex items-center gap-3"> + <div class="relative flex-1 max-w-md"> + <input @bind="searchInput" @bind:event="oninput" @onkeyup="HandleKeyUp" type="text" placeholder="@AppLocalization.Text("common.searchByUser", "Search by user, action, section, or details...")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2.5 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingAuditLogs", "Loading audit logs...")" /> + } + else + { + <!-- Logs Table --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.date", "Timestamp")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.user", "User")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.module", "Module")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actions", "Action")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.description", "Description")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.ipAddress", "IP Address")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100 font-sans"> + @if (PagedLogs.Any()) + { + @foreach (var entry in PagedLogs.Select((log, index) => (log, index))) + { + var log = entry.log; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500 align-top">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4 text-xs font-mono text-slate-500 whitespace-nowrap align-top"> + @AppLocalization.LocalizeDigits(log.CreatedAt.ToString("dd-MM-yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture)) + </td> + <td class="px-6 py-4 align-top"> + <div class="flex items-center gap-2"> + <span class="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-[10px] font-bold shrink-0"> + @(GetAuditUserName(log).Length > 0 ? GetAuditUserName(log)[0].ToString().ToUpper() : "S") + </span> + <div> + <p class="text-xs font-semibold text-slate-800">@GetAuditUserName(log)</p> + <p class="text-[10px] text-slate-400">@@@log.Username</p> + </div> + </div> + </td> + <td class="px-6 py-4 align-top"> + <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 text-slate-700 border border-slate-200"> + @AppLocalization.ModuleName(log.Module) + </span> + </td> + <td class="px-6 py-4 align-top"> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold @GetActionBadge(log.Action)"> + @AppLocalization.ActionName(log.Action) + </span> + </td> + <td class="px-6 py-4 text-xs text-slate-700 whitespace-normal break-words align-top"> + @AppLocalization.AuditDescription(log.Description) + </td> + <td class="px-6 py-4 text-xs font-mono text-slate-500 whitespace-nowrap align-top"> + @(string.IsNullOrWhiteSpace(log.IpAddress) ? AppLocalization.Text("common.na", "N/A") : AppLocalization.LocalizeDigits(log.IpAddress)) + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="7" Icon="history" Title="@AppLocalization.Text("common.noResults", "No audit logs")" Subtitle="@AppLocalization.Text("common.noResults", "No security events or administrator operations were found.")" /> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="TotalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } +</div> + +@code { + private bool isLoading = true; + private List<AuditLogDto> 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<AuditLogDto> 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<PagedResult<AuditLogDto>>(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 async Task ApplySearch() + { + currentPage = 1; + await LoadLogs(); + } + + private async Task ResetSearch() + { + searchInput = ""; + currentPage = 1; + await LoadLogs(); + } + + private async Task HandleKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await ApplySearch(); + } + } + + private string GetAuditUserName(AuditLogDto log) + { + if (string.Equals(log.UserFullName, "System Admin", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.Text("common.systemAdmin", "System Admin"); + } + + if (string.Equals(log.UserFullName, "System", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.Text("notification.system", "System"); + } + + return Content.GetText(log.UserFullName, log.UserFullNameMy); + } + + private string BuildAuditLogsUrl() + { + var query = new List<string> + { + $"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..9383e212a3b4287baa5073db41aedfdfc4ccc13a --- /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 + +<PageTitle>@AppLocalization.Text("common.signIn", "Sign In") - Taskify</PageTitle> + +<div class="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-10 sm:px-6 lg:px-8"> + <div class="w-full max-w-md space-y-7 rounded-xl border border-slate-200 bg-white p-8 shadow-sm"> + <div> + <div class="flex justify-center"> + <span class="flex h-12 w-12 items-center justify-center rounded-xl bg-violet-600 text-white shadow-sm"> + <i data-lucide="clipboard-check" class="h-6 w-6"></i> + </span> + </div> + <h2 class="mt-5 text-center text-2xl font-bold text-slate-900">@AppLocalization.Text("common.signIn", "Sign In") - Taskify</h2> + <p class="mt-2 text-center text-sm text-slate-500">@AppLocalization.PageDescription("dashboard", "Continue to your work dashboard.")</p> + </div> + + <form method="post" action="/account/login" class="space-y-5"> + <AntiforgeryToken /> + <input type="hidden" name="ReturnUrl" value="@ReturnUrl" /> + + <div class="space-y-4"> + <div> + <label for="usernameOrEmail" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("common.usernameOrEmail", "Username or Email")</label> + <input id="usernameOrEmail" name="UsernameOrEmail" required placeholder="@AppLocalization.Text("common.searchPlaceholder", "Enter username or email")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div class="relative"> + <label for="password" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("common.password", "Password")</label> + <input id="password" name="Password" type="@(showPassword ? "text" : "password")" required placeholder="@AppLocalization.Text("common.password", "Password")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all pr-10" /> + <button type="button" @onclick="TogglePassword" class="absolute right-3 top-[28px] text-slate-400 hover:text-slate-600 focus:outline-none"> + <i data-lucide="@(showPassword ? "eye-off" : "eye")" class="h-5 w-5"></i> + </button> + </div> + </div> + + <div class="flex items-center justify-between"> + <div class="flex items-center"> + <input id="rememberMe" name="RememberMe" type="checkbox" value="true" + class="h-4 w-4 rounded border-slate-350 text-slate-900 focus:ring-slate-900 focus:outline-none" /> + <label for="rememberMe" class="ml-2 block text-sm text-slate-600 font-medium">@AppLocalization.Text("common.rememberMe", "Remember me")</label> + </div> + </div> + + @if (ShowUnavailableError) + { + <div class="p-3 rounded-lg bg-amber-50 border border-amber-100"> + <p class="text-sm text-amber-800 text-center">@AppLocalization.Text("result.apiUnavailable", "Unable to reach the API server. Start TaskTrackingSystem.WebApi and try again.")</p> + </div> + } + else if (ShowError) + { + <div class="p-3 rounded-lg bg-red-50 border border-red-100"> + <p class="text-sm text-red-600 text-center">@AppLocalization.ErrorMessage(ResultMessages.InvalidCredentials)</p> + </div> + } + + @if (ShowLoggedOutMessage) + { + <div class="p-3 rounded-lg bg-emerald-50 border border-emerald-100"> + <p class="text-sm text-emerald-700 text-center">@AppLocalization.Text("result.signedOut", "You have been signed out successfully.")</p> + </div> + } + else if (ShowResetMessage) + { + <div class="p-3 rounded-lg bg-emerald-50 border border-emerald-100"> + <p class="text-sm text-emerald-700 text-center">@AppLocalization.Text("result.passwordUpdated", "Password updated successfully. Please sign in with your new password.")</p> + </div> + } + + <p class="text-center text-sm text-slate-600"> + @AppLocalization.Text("auth.needAccount", "Need an account?") + <a href="/register" class="font-medium text-slate-900 hover:underline">@AppLocalization.Text("auth.register", "Register")</a> + </p> + + <div> + <button type="submit" class="group relative flex w-full justify-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-700"> + @AppLocalization.Text("common.signIn", "Sign In") + </button> + </div> + + <p class="text-center text-sm text-slate-600"> + <a href="/reset-password" class="font-medium text-slate-900 hover:underline">@AppLocalization.Text("auth.resetPassword", "Reset password")</a> + </p> + </form> + </div> +</div> + +@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<string> 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<TaskTrackingSystem.Shared.Models.Menu.MenuDto> 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..5f89bbbd13a9c23deacf8ef43d4afd9b26d73e15 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Auth/Register.razor @@ -0,0 +1,100 @@ +@page "/register" +@using TaskTrackingSystem.WebApp.Components.Partial +@layout Layout.EmptyLayout +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] +@inject IJSRuntime JS + +<PageTitle>@AppLocalization.Text("auth.registerTitle", "Register - Taskify")</PageTitle> + +<div class="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-10 sm:px-6 lg:px-8"> + <div class="w-full max-w-md space-y-7 rounded-xl border border-slate-200 bg-white p-8 shadow-sm"> + <div> + <div class="flex justify-center"> + <span class="flex h-12 w-12 items-center justify-center rounded-xl bg-violet-600 text-white shadow-sm"> + <i data-lucide="clipboard-check" class="h-6 w-6"></i> + </span> + </div> + <h2 class="mt-5 text-center text-2xl font-bold text-slate-900">@AppLocalization.Text("auth.registerAccountTitle", "Create your Taskify account")</h2> + <p class="mt-2 text-center text-sm text-slate-600"> + @AppLocalization.Text("auth.alreadyHaveAccess", "Already have access?") + <a href="/login" class="font-semibold text-violet-600 hover:underline">@AppLocalization.Text("common.signIn", "Sign in")</a> + </p> + </div> + + <form method="post" action="/account/register" class="space-y-5"> + <AntiforgeryToken /> + + <div class="space-y-4"> + <div class="grid grid-cols-2 gap-4"> + <div> + <label for="firstName" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("auth.firstName", "First Name")</label> + <input id="firstName" name="FirstName" required placeholder="@AppLocalization.Text("auth.firstNamePlaceholder", "First name")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div> + <label for="lastName" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("auth.lastName", "Last Name")</label> + <input id="lastName" name="LastName" required placeholder="@AppLocalization.Text("auth.lastNamePlaceholder", "Last name")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + </div> + <div> + <label for="username" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("common.user", "Username")</label> + <input id="username" name="Username" required placeholder="@AppLocalization.Text("common.user", "Username").ToLowerInvariant()" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div> + <label for="email" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("common.email", "Email")</label> + <input id="email" name="Email" type="email" required placeholder="user@example.com" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div> + <label for="phone" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("auth.phone", "Phone (Optional)")</label> + <input id="phone" name="Phone" placeholder="@AppLocalization.Text("auth.phonePlaceholder", "Phone number")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div class="relative"> + <label for="password" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("common.password", "Password")</label> + <input id="password" name="Password" type="@(showPassword ? "text" : "password")" required placeholder="@AppLocalization.Text("common.password", "Password")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all pr-10" /> + <button type="button" @onclick="TogglePassword" class="absolute right-3 top-[28px] text-slate-400 hover:text-slate-600 focus:outline-none"> + <i data-lucide="@(showPassword ? "eye-off" : "eye")" class="h-5 w-5"></i> + </button> + </div> + </div> + + @if (!string.IsNullOrEmpty(Error)) + { + <div class="p-3 rounded-lg border" style="background-color: var(--status-warning-bg); border-color: rgba(153, 27, 27, 0.2);"> + <p class="text-sm text-center" style="color: var(--text-primary); font-weight: 500;">@ErrorMessage</p> + </div> + } + + <div> + <button type="submit" class="group relative flex w-full justify-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-700"> + @AppLocalization.Text("auth.register", "Register") + </button> + </div> + </form> + </div> +</div> + +@code { + [SupplyParameterFromQuery(Name = "error")] + private string? Error { get; set; } + + private string ErrorMessage => AppLocalization.ErrorMessage(Error, ResultMessages.RegistrationFailed); + + 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..86dfa2226358b2b83e1bed1c7dbc9cefe0a3d510 --- /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 + +<PageTitle>@AppLocalization.Text("auth.resetPassword", "Reset Password") - Taskify</PageTitle> + +<div class="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-10 sm:px-6 lg:px-8"> + <div class="w-full max-w-md space-y-7 rounded-xl border border-slate-200 bg-white p-8 shadow-sm"> + <div> + <div class="flex justify-center"> + <span class="flex h-12 w-12 items-center justify-center rounded-xl bg-violet-600 text-white shadow-sm"> + <i data-lucide="clipboard-check" class="h-6 w-6"></i> + </span> + </div> + <h2 class="mt-5 text-center text-2xl font-bold text-slate-900">@AppLocalization.Text("auth.resetPassword", "Reset your password")</h2> + <p class="mt-2 text-center text-sm text-slate-600"> + @AppLocalization.Text("auth.resetPasswordHelp", "Use the recovery code configured in the API to set a new password.") + </p> + </div> + + <form method="post" action="/account/reset-password" class="space-y-5"> + <AntiforgeryToken /> + + <div class="space-y-4"> + <div> + <label for="usernameOrEmail" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("auth.usernameOrEmail", "Username or Email")</label> + <input id="usernameOrEmail" name="UsernameOrEmail" required placeholder="@AppLocalization.Text("auth.enterUsernameOrEmail", "Enter username or email")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div> + <label for="recoveryCode" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("auth.recoveryCode", "Recovery Code")</label> + <input id="recoveryCode" name="RecoveryCode" required placeholder="@AppLocalization.Text("auth.enterRecoveryCode", "Enter recovery code")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div> + <label for="newPassword" class="block text-sm font-medium text-slate-700 mb-1">@AppLocalization.Text("auth.newPassword", "New Password")</label> + <input id="newPassword" name="NewPassword" type="password" required placeholder="@AppLocalization.Text("validation.passwordMinLengthRule", "Min 8 characters")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + </div> + + @if (ShowError) + { + <div class="p-3 rounded-lg bg-red-50 border border-red-100"> + <p class="text-sm text-red-600 text-center">@ErrorMessage</p> + </div> + } + + <div class="flex items-center justify-between"> + <a href="/login" class="text-sm font-medium text-violet-600 hover:underline">@AppLocalization.Text("common.backToDashboard", "Back to sign in")</a> + <button type="submit" class="group relative flex justify-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-700"> + @AppLocalization.Text("auth.resetPassword", "Reset Password") + </button> + </div> + </form> + </div> +</div> + +@code { + [SupplyParameterFromQuery(Name = "error")] + private string? Error { get; set; } + + private bool ShowError => !string.IsNullOrWhiteSpace(Error); + private string ErrorMessage => AppLocalization.ErrorMessage(Error, AppLocalization.Text("auth.unableToResetPassword", "Unable to reset password.")); + + 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..d7233a13f018501993c7f324fb769d3f6d30eded --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Common/Error.razor @@ -0,0 +1,35 @@ +@page "/Error" +@using System.Diagnostics + +<PageTitle>@AppLocalization.Text("error.title", "Error")</PageTitle> + +<h1 class="text-danger">@AppLocalization.Text("error.heading", "Error")</h1> +<h2 class="text-danger">@AppLocalization.Text("error.processingRequest", "An error occurred while processing your request.")</h2> + +@if (ShowRequestId) +{ + <p> + <strong>@AppLocalization.Text("error.requestId", "Request ID"):</strong> <code>@RequestId</code> + </p> +} + +<h3>@AppLocalization.Text("error.developmentMode", "Development Mode")</h3> +<p> + @AppLocalization.Text("error.developmentDetails", "Switching to the Development environment will display more detailed information about the error that occurred.") +</p> +<p> + <strong>@AppLocalization.Text("error.developmentWarning", "The Development environment should not be enabled for deployed applications.")</strong> + @AppLocalization.Text("error.developmentSensitive", "It can expose sensitive exception details to end users.") + @AppLocalization.Text("error.developmentInstructions", "For local debugging, set the ASPNETCORE_ENVIRONMENT environment variable to Development and restart the application.") +</p> + +@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 + +<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> + @foreach (var metric in Metrics) + { + <KpiCard Icon="@metric.Icon" + Value="@metric.Value" + Label="@metric.Label" + BadgeText="@metric.BadgeText" + IconWrapClass="@metric.IconWrapClass" + BadgeClass="@metric.BadgeClass" + CardClass="rounded-xl border border-slate-200 bg-white p-4 text-slate-900 shadow-sm shadow-slate-200/50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100" /> + } +</div> + +@code { + [Parameter] public IReadOnlyList<DashboardMetric> Metrics { get; set; } = Array.Empty<DashboardMetric>(); +} 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 + +<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"> + @foreach (var metric in Metrics) + { + <KpiCard Icon="@metric.Icon" + Value="@metric.Value" + Label="@metric.Label" + BadgeText="@metric.BadgeText" + IconWrapClass="@metric.IconWrapClass" + BadgeClass="@metric.BadgeClass" /> + } +</div> + +@code { + [Parameter] public IReadOnlyList<DashboardMetric> Metrics { get; set; } = Array.Empty<DashboardMetric>(); +} 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 @@ +<div class="@CardClass"> + <div class="flex items-center justify-between"> + <div class="@IconWrapClass"> + <i data-lucide="@Icon" class="h-5 w-5"></i> + </div> + @if (!string.IsNullOrWhiteSpace(BadgeText)) + { + <span class="@BadgeClass">@BadgeText</span> + } + </div> + <p class="mt-5 text-3xl font-bold text-slate-950 dark:text-slate-50">@Value</p> + <p class="mt-2 text-sm text-slate-500 dark:text-slate-400">@Label</p> +</div> + +@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 + +<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> + @foreach (var metric in Metrics) + { + <KpiCard Icon="@metric.Icon" + Value="@metric.Value" + Label="@metric.Label" + BadgeText="@metric.BadgeText" + IconWrapClass="@metric.IconWrapClass" + BadgeClass="@metric.BadgeClass" /> + } +</div> + +@code { + [Parameter] public IReadOnlyList<DashboardMetric> Metrics { get; set; } = Array.Empty<DashboardMetric>(); +} 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..932f86b81e0487d7b2e24d18d5d78ce88f41e1fe --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/EmployeeDashboard.razor @@ -0,0 +1,532 @@ +@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 + +<PageTitle>@AppLocalization.Text("dashboard.employeeTitle", "Employee Dashboard")</PageTitle> + +<div class="min-h-screen bg-slate-50 px-4 py-6 text-slate-900 sm:px-6 lg:px-8 dark:bg-slate-950 dark:text-slate-50"> + <div class="mx-auto max-w-[1440px] space-y-6"> + <div class="rounded-xl border border-slate-200 bg-white px-8 py-8 shadow-sm"> + <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> + <div> + <p class="text-sm font-semibold uppercase tracking-[0.08em] text-violet-700">@AppLocalization.Text("dashboard.teamMember", "Team Member")</p> + <h2 class="mt-2 text-3xl font-bold tracking-tight text-slate-950">@AppLocalization.Text("dashboard.myWork", "My Work")</h2> + <p class="mt-3 max-w-2xl text-sm text-slate-600"> + @AppLocalization.Text("dashboard.employeeIntro", "Tasks, deadlines, and your progress in one place.") + </p> + </div> + + <div class="flex flex-wrap gap-3"> + <a href="/tasks/all" class="inline-flex items-center rounded-2xl bg-violet-700 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-600"> + <i data-lucide="check-square" class="mr-2 h-4 w-4"></i> + @AppLocalization.Text("dashboard.viewTasks", "View Tasks") + </a> + <a href="/reports/tasks" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 shadow-sm transition-colors hover:bg-slate-50"> + <i data-lucide="bar-chart-3" class="mr-2 h-4 w-4"></i> + @AppLocalization.Text("page.reports", "Reports") + </a> + </div> + </div> + </div> + + @if (isLoading) + { + <div class="rounded-xl border border-slate-200 bg-white px-6 py-12 text-center shadow-sm"> + <div class="mx-auto flex items-center justify-center gap-3 text-slate-500"> + <svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> + </svg> + <span class="text-sm font-medium">@AppLocalization.Text("dashboard.loadingDashboard", "Loading dashboard...")</span> + </div> + </div> + } + else if (!string.IsNullOrWhiteSpace(errorMessage)) + { + <div class="rounded-xl border border-rose-200 bg-rose-50 px-6 py-5 text-sm text-rose-700 shadow-sm"> + @errorMessage + </div> + } + else + { + <EmployeeOverview Metrics="SummaryMetrics" /> + + <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(280px,1fr)]"> + <div class="space-y-5"> + <div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> + <div class="flex items-center justify-between gap-4 border-b border-slate-100 px-6 py-5"> + <div class="flex items-center gap-3"> + <span class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-violet-200 bg-violet-50 text-violet-700"> + <i data-lucide="timer-reset" class="h-4 w-4"></i> + </span> + <div> + <h3 class="text-xl font-bold text-slate-950">@AppLocalization.Text("status.inprogress", "In Progress")</h3> + <p class="text-sm text-slate-500">@AppLocalization.Text("dashboard.tasksInProgressDesc", "Tasks currently being worked on")</p> + </div> + </div> + <span class="rounded-full bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-600">@AppLocalization.Number(InProgressTasks.Count)</span> + </div> + + <div class="max-h-[420px] divide-y divide-slate-100 overflow-y-auto"> + @if (InProgressTasks.Any()) + { + @foreach (var task in InProgressTasks) + { + <div class="flex items-center gap-4 px-6 py-5"> + <span class="mt-1 h-2.5 w-2.5 rounded-full" style="background-color: @GetStatusColor(task.StatusId);"></span> + <div class="min-w-0 flex-1"> + <div class="truncate text-sm font-semibold text-slate-950">@Content.GetTaskTitle(task)</div> + <div class="truncate text-xs text-slate-500">@GetProjectName(task.ProjectId)</div> + </div> + <div class="text-right"> + <div class="text-sm text-slate-500">@DisplayFormats.Date(task.DueDate)</div> + <div class="text-sm font-semibold @GetDueTextClass(task)">@GetDueLabel(task)</div> + </div> + </div> + } + } + else + { + <div class="px-6 py-10 text-sm text-slate-500">@AppLocalization.Text("dashboard.noTasksInProgress", "No tasks in progress right now.")</div> + } + </div> + </div> + + <div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> + <div class="flex items-center justify-between gap-4 border-b border-slate-100 px-6 py-5"> + <div class="flex items-center gap-3"> + <span class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-sky-200 bg-sky-50 text-sky-600"> + <i data-lucide="clock-3" class="h-4 w-4"></i> + </span> + <div> + <h3 class="text-xl font-bold text-slate-950">@AppLocalization.Text("dashboard.todoUpcoming", "To Do & Upcoming")</h3> + <p class="text-sm text-slate-500">@AppLocalization.Text("dashboard.todoUpcomingDesc", "Tasks waiting for action or coming up soon")</p> + </div> + </div> + <span class="rounded-full bg-slate-100 px-3 py-1 text-sm font-semibold text-slate-600">@AppLocalization.Number(TodoUpcomingTasks.Count)</span> + </div> + + <div class="max-h-[420px] divide-y divide-slate-100 overflow-y-auto"> + @if (TodoUpcomingTasks.Any()) + { + @foreach (var task in TodoUpcomingTasks) + { + <div class="flex items-center gap-4 px-6 py-5"> + <span class="mt-1 h-2.5 w-2.5 rounded-full" style="background-color: @GetStatusColor(task.StatusId);"></span> + <div class="min-w-0 flex-1"> + <div class="truncate text-sm font-semibold text-slate-950">@Content.GetTaskTitle(task)</div> + <div class="truncate text-xs text-slate-500">@GetProjectName(task.ProjectId)</div> + </div> + <div class="text-right"> + <div class="text-sm text-slate-500">@DisplayFormats.Date(task.DueDate)</div> + <div class="text-sm font-semibold @GetDueTextClass(task)">@GetDueLabel(task)</div> + </div> + </div> + } + } + else + { + <div class="px-6 py-10 text-sm text-slate-500">@AppLocalization.Text("dashboard.noUpcomingTasks", "No upcoming tasks found.")</div> + } + </div> + </div> + + <div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> + <div class="flex items-center justify-between gap-4 border-b border-rose-100 px-6 py-5"> + <div class="flex items-center gap-3"> + <span class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-rose-200 bg-rose-50 text-rose-600"> + <i data-lucide="triangle-alert" class="h-4 w-4"></i> + </span> + <div> + <h3 class="text-xl font-bold text-slate-950">@AppLocalization.Text("status.overdue", "Overdue")</h3> + <p class="text-sm text-slate-500">@AppLocalization.Text("dashboard.overdueDesc", "Tasks past their due date")</p> + </div> + </div> + <span class="rounded-full bg-rose-50 px-3 py-1 text-sm font-semibold text-rose-600">@AppLocalization.Number(OverdueTasks.Count)</span> + </div> + + <div class="max-h-[320px] divide-y divide-rose-100 overflow-y-auto"> + @if (OverdueTasks.Any()) + { + @foreach (var task in OverdueTasks) + { + <div class="flex items-center gap-4 px-6 py-5"> + <span class="mt-1 inline-flex h-6 w-6 items-center justify-center rounded-full border border-rose-200 text-rose-500"> + <i data-lucide="triangle-alert" class="h-3.5 w-3.5"></i> + </span> + <div class="min-w-0 flex-1"> + <div class="truncate text-sm font-semibold text-slate-950">@Content.GetTaskTitle(task)</div> + <div class="truncate text-xs text-slate-500">@GetProjectName(task.ProjectId)</div> + </div> + <div class="text-right"> + <div class="text-sm text-slate-500">@DisplayFormats.Date(task.DueDate)</div> + <div class="text-sm font-semibold text-rose-600">@DaysOverdue(task)</div> + </div> + </div> + } + } + else + { + <div class="px-6 py-10 text-sm text-slate-500">@AppLocalization.Text("dashboard.noOverdueTasks", "No overdue tasks. Nice work.")</div> + } + </div> + </div> + </div> + + <div class="space-y-5"> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex items-start justify-between gap-4"> + <div> + <div class="flex items-center gap-3"> + <button type="button" @onclick="PrevMonth" class="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50"> + <i data-lucide="chevron-left" class="h-4 w-4"></i> + </button> + <h3 class="text-xl font-bold text-slate-950 min-w-[120px]">@CalendarMonthLabel</h3> + <button type="button" @onclick="NextMonth" class="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50"> + <i data-lucide="chevron-right" class="h-4 w-4"></i> + </button> + </div> + <p class="mt-2 text-sm text-slate-500">@CalendarTodayLabel</p> + </div> + </div> + + <div class="mt-5 grid grid-cols-7 gap-2 text-center text-xs font-semibold uppercase tracking-wider text-slate-400"> + @foreach (var day in WeekdayLabels) + { + <span>@day</span> + } + </div> + + <div class="mt-3 grid grid-cols-7 gap-2"> + @foreach (var cell in CalendarCells) + { + <button type="button" @onclick="() => SelectDate(cell.Date)" class="@GetCalendarCellClasses(cell)"> + <div class="flex h-full flex-col justify-between"> + <div class="text-sm font-semibold @(cell.IsCurrentMonth ? (cell.IsSelected ? "text-white" : "text-slate-900") : "text-slate-400")">@AppLocalization.Number(cell.Date.Day)</div> + <div class="flex items-center gap-1"> + @foreach (var dot in cell.TaskDots.Take(3)) + { + <span class="h-1.5 w-1.5 rounded-full" style="background-color: @dot;"></span> + } + @if (cell.TaskDots.Count > 3) + { + <span class="text-[10px] leading-none text-slate-400">+@AppLocalization.Number(cell.TaskDots.Count - 3)</span> + } + </div> + </div> + </button> + } + </div> + </div> + + <div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> + <div class="border-b border-slate-100 px-6 py-5"> + <h3 class="text-xl font-bold text-slate-950">@AppLocalization.Text("dashboard.relatedTasks", "Related Tasks")</h3> + <p class="mt-1 text-sm text-slate-500">@AppLocalization.Text("dashboard.tasksMarkedOn", "Tasks marked on {date}").Replace("{date}", SelectedDateLabel)</p> + </div> + + <div class="divide-y divide-slate-100"> + @if (SelectedDateTasks.Any()) + { + @foreach (var task in SelectedDateTasks) + { + <div class="flex items-center gap-4 px-6 py-4"> + <span class="mt-1 h-2.5 w-2.5 rounded-full" style="background-color: @GetStatusColor(task.StatusId);"></span> + <div class="min-w-0 flex-1"> + <div class="truncate text-sm font-semibold text-slate-950">@Content.GetTaskTitle(task)</div> + <div class="truncate text-xs text-slate-500">@GetProjectName(task.ProjectId)</div> + </div> + <div class="text-right text-xs text-slate-500"> + <div>@DisplayFormats.Date(task.DueDate)</div> + <div class="@GetDueTextClass(task)">@GetDueLabel(task)</div> + </div> + </div> + } + } + else + { + <div class="px-6 py-10 text-sm text-slate-500">@AppLocalization.Text("dashboard.noTasksForDay", "No tasks are scheduled for this day.")</div> + } + </div> + </div> + </div> + </div> + } + </div> +</div> + +@code { + private bool isLoading = true; + private string? errorMessage; + private long currentUserId; + private List<TaskDto> tasks = new(); + private List<ProjectDto> projects = new(); + private DateTime selectedDate = DateTime.Today; + + private string[] WeekdayLabels => AppLocalization.IsBurmeseCulture + ? ["တနင်္ဂနွေ", "တနင်္လာ", "အင်္ဂါ", "ဗုဒ္ဓဟူး", "ကြာသပတေး", "သောကြာ", "စနေ"] + : ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + private List<CalendarCellViewModel> CalendarCells => BuildCalendarCells(); + private List<TaskDto> SelectedDateTasks => tasks + .Where(task => task.DueDate.Date == selectedDate.Date) + .OrderBy(task => task.StatusId) + .ThenBy(task => task.DueDate) + .ToList(); + + private string SelectedDateLabel => GetDisplayDateLabel(selectedDate); + private string CalendarMonthLabel => GetMonthYearLabel(selectedDate); + private string CalendarTodayLabel => GetDisplayDateLabel(DateTime.Today); + 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<TaskDto> InProgressTasks => tasks + .Where(task => task.StatusId == AppTaskStatus.InProgress) + .OrderBy(task => task.DueDate) + .ToList(); + + private List<TaskDto> TodoUpcomingTasks => tasks + .Where(task => task.StatusId == AppTaskStatus.Todo || (task.StatusId != AppTaskStatus.Done && task.DueDate.Date >= DateTime.Today)) + .OrderBy(task => task.DueDate) + .ToList(); + + private List<TaskDto> OverdueTasks => tasks + .Where(task => task.DueDate.Date < DateTime.Today && task.StatusId != AppTaskStatus.Done) + .OrderBy(task => task.DueDate) + .ToList(); + + private List<DashboardMetric> 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<Result<List<TaskDto>>>($"User/{currentUserId}/tasks", Serialization.CaseInsensitive); + var projectsResult = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive); + + if (tasksResult?.IsSuccess == true && tasksResult.Value != null) + { + tasks = tasksResult.Value.ToList(); + } + else + { + tasks = new List<TaskDto>(); + } + selectedDate = DateTime.Today; + + projects = projectsResult ?? new List<ProjectDto>(); + } + catch (Exception ex) + { + errorMessage = $"{AppLocalization.Text("common.error", "Error")}: {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<DashboardMetric> 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<DashboardMetric> + { + new("timer-reset", AppLocalization.Text("dashboard.active", "active"), AppLocalization.Number(inProgress), AppLocalization.Text("status.inprogress", "In Progress"), "rounded-2xl bg-violet-700 p-3 text-white", "text-sm font-semibold text-violet-700"), + new("list-checks", AppLocalization.Text("dashboard.queued", "queued"), AppLocalization.Number(todo), AppLocalization.Text("status.todo", "To Do"), "rounded-2xl bg-cyan-600 p-3 text-white", "text-sm font-semibold text-cyan-600"), + new("calendar-clock", AppLocalization.Text("dashboard.soon", "soon"), AppLocalization.Number(dueSoon), AppLocalization.Text("dashboard.dueSoon", "Due Soon"), "rounded-2xl bg-amber-500 p-3 text-white", "text-sm font-semibold text-amber-600"), + new("circle-check-big", AppLocalization.Text("dashboard.done", "done"), AppLocalization.Number(completed), AppLocalization.Text("status.done", "Completed"), "rounded-2xl bg-emerald-600 p-3 text-white", "text-sm font-semibold text-emerald-600"), + new("triangle-alert", AppLocalization.Text("dashboard.late", "late"), AppLocalization.Number(overdue), AppLocalization.Text("status.overdue", "Overdue"), "rounded-2xl bg-rose-600 p-3 text-white", "text-sm font-semibold text-rose-600") + }; + } + + private List<CalendarCellViewModel> 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<CalendarCellViewModel>(); + for (var i = 0; i < 42; i++) + { + var date = start.AddDays(i); + dateMap.TryGetValue(date.Date, out var dayTasks); + dayTasks ??= new List<TaskDto>(); + + 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) + { + var project = projects.FirstOrDefault(project => project.Id == projectId); + return project is null ? $"Project #{projectId}" : Content.GetProjectName(project); + } + + 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<string> + { + "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 AppLocalization.Text("dashboard.done", "Done"); + } + + if (days < 0) + { + return AppLocalization.Text("dashboard.daysAgo", "{count}d ago").Replace("{count}", AppLocalization.Number(Math.Abs(days))); + } + + if (days == 0) + { + return AppLocalization.Text("dashboard.today", "Today"); + } + + return AppLocalization.Text("dashboard.daysLeft", "{count}d left").Replace("{count}", AppLocalization.Number(days)); + } + + 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 + ? AppLocalization.Text("dashboard.dueToday", "Due today") + : AppLocalization.Text("dashboard.daysAgo", "{count}d ago").Replace("{count}", AppLocalization.Number(days)); + } + + private string GetDisplayDateLabel(DateTime date) => AppLocalization.IsBurmeseCulture + ? $"{AppLocalization.MonthName(date, true)} {AppLocalization.Number(date.Day)}" + : date.ToString("dddd, MMM d"); + + private string GetMonthYearLabel(DateTime date) => AppLocalization.IsBurmeseCulture + ? $"{AppLocalization.Number(date.Year)} {AppLocalization.MonthName(date)}" + : date.ToString("MMMM yyyy"); + + private sealed record CalendarCellViewModel( + DateTime Date, + bool IsCurrentMonth, + bool IsSelected, + List<string> 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..f64de2806c297ea32e68a1d0374c5377f9d13989 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/Home.razor @@ -0,0 +1,1074 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("dashboard", "Dashboard")</PageTitle> + +<div class="space-y-8 text-slate-900 dark:text-slate-100"> + <div class="rounded-xl border border-slate-200 bg-white px-8 py-8 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> + <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> + <div> + <p class="text-sm font-semibold uppercase tracking-[0.08em] text-teal-700 dark:text-teal-300">@AppLocalization.Text("dashboard.overview", "Overview")</p> + <h2 class="mt-2 text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">@AppLocalization.Text("dashboard.welcomeBack", "Welcome back, {name}").Replace("{name}", currentUserFullName)</h2> + <p class="mt-3 max-w-2xl text-sm text-slate-600 dark:text-slate-300"> + @AppLocalization.Text("dashboard.adminIntro", "Projects, users, and team progress at a glance.") + </p> + </div> + <div class="flex flex-wrap gap-3"> + @if (canCreateTask) + { + <a href="/tasks/all" class="inline-flex items-center rounded-lg bg-teal-700 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500"> + <i data-lucide="plus" class="mr-2 h-4 w-4"></i> + @AppLocalization.Text("task.newTask", "New Task") + </a> + } + <a href="/reports/tasks" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"> + <i data-lucide="bar-chart-3" class="mr-2 h-4 w-4"></i> + @AppLocalization.Text("page.reports", "Reports") + </a> + </div> + </div> + </div> + + <AdminOverview Metrics="SummaryMetrics" /> + + <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(280px,1fr)]"> + <div class="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"> + <div class="flex items-start justify-between gap-4"> + <div> + <h3 class="text-xl font-bold text-slate-900">@AppLocalization.Text("dashboard.projectActivity", "Project Activity")</h3> + <p class="mt-1 text-sm text-slate-500">@AppLocalization.Text("dashboard.projectActivityDesc", "Active vs completed tasks per month.")</p> + </div> + <div class="text-sm text-slate-500">@ActivityRangeLabel</div> + </div> + + <div class="mt-6"> + <div class="relative"> + <svg viewBox="0 0 760 340" class="h-[340px] w-full"> + <defs> + <linearGradient id="activityFill" x1="0" x2="0" y1="0" y2="1"> + <stop offset="0%" stop-color="#0f766e" stop-opacity="0.18" /> + <stop offset="100%" stop-color="#0f766e" stop-opacity="0" /> + </linearGradient> + </defs> + + @for (var i = 0; i < 5; i++) + { + var y = 60 + (i * 55); + <line x1="48" y1="@y" x2="730" y2="@y" stroke="#e2e8f0" stroke-width="1" /> + } + + @for (var i = 0; i < 6; i++) + { + var x = 60 + (i * 125); + <line x1="@x" y1="40" x2="@x" y2="280" stroke="#e2e8f0" stroke-width="1" /> + } + + <path d="@ActivityAreaPath" fill="url(#activityFill)" /> + <path d="@ActivityLinePath" fill="none" stroke="#0f766e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> + + @for (var i = 0; i < ActivityMonths.Count; i++) + { + var index = i; + var point = ActivityPoints[i]; + <circle cx="@point.X" cy="@point.Y" r="5" + fill="#0f766e" + stroke="#ccfbf1" + stroke-width="3" + @onmouseenter="() => SetHoveredActivity(index)" + @onmouseleave="ClearHoveredActivity" /> + } + </svg> + + @if (hoveredActivity is not null) + { + <div class="pointer-events-none absolute z-20 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-lg shadow-slate-200/70" + style="@HoveredActivityStyle"> + <div class="text-sm font-semibold text-slate-900">@hoveredActivity.Value.Label</div> + <div class="mt-2 text-sm text-emerald-600">@AppLocalization.Text("dashboard.completedCount", "Completed: {count}").Replace("{count}", AppLocalization.Number(hoveredActivity.Value.Completed))</div> + <div class="mt-1 text-sm text-violet-600">@AppLocalization.Text("dashboard.activeCount", "Active: {count}").Replace("{count}", AppLocalization.Number(hoveredActivity.Value.Active))</div> + </div> + } + </div> + + <div class="mt-4 flex items-center justify-between text-xs text-slate-500"> + @foreach (var label in ActivityLabels) + { + <span class="w-1/6 text-center">@label</span> + } + </div> + </div> + </div> + + <ChartCard Title="@AppLocalization.Text("dashboard.taskDistribution", "Task Distribution")" Subtitle="@AppLocalization.Text("dashboard.totalTasks", "{count} total tasks").Replace("{count}", AppLocalization.Number(TotalTasksCount))"> + <ChildContent> + <DonutChart Items="DistributionChartItems" /> + </ChildContent> + </ChartCard> + </div> + + <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(280px,0.75fr)]"> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900"> + <div class="flex items-center justify-between gap-4"> + <div> + <h3 class="text-xl font-bold text-slate-900">@AppLocalization.Text("dashboard.allProjects", "All Projects")</h3> + <p class="mt-1 text-sm text-slate-500">@AppLocalization.Text("dashboard.projectsSnapshot", "A quick snapshot of the current project list.")</p> + </div> + <a href="/projects/all" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-teal-700 shadow-sm transition-colors hover:bg-teal-50 dark:border-slate-700 dark:bg-slate-900 dark:text-teal-300 dark:hover:bg-slate-800"> + @AppLocalization.Text("dashboard.viewAll", "View all") + </a> + </div> + + <div class="mt-5 max-h-[560px] space-y-3 overflow-y-auto pr-1"> + @foreach (var project in ProjectListItems) + { + var progress = GetProjectProgress(project.Id); + <div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4"> + <div class="flex items-start justify-between gap-4"> + <div class="min-w-0"> + <div class="flex flex-wrap items-center gap-2"> + <p class="truncate text-sm font-semibold text-slate-900">@Content.GetProjectName(project)</p> + <span class="@GetProjectBadgeClasses(project)">@GetProjectBadgeLabel(project)</span> + </div> + <p class="mt-1 text-xs text-slate-500">@DisplayFormats.Date(project.StartDate) - @DisplayFormats.Date(project.EndDate)</p> + </div> + <div class="text-right"> + <div class="text-sm font-semibold text-slate-900">@AppLocalization.Number(progress.CompletedTasksCount)/@AppLocalization.Number(progress.TotalTasksCount) @AppLocalization.Text("dashboard.tasksUnit", "tasks")</div> + <div class="text-xs text-slate-500">@AppLocalization.Number(progress.CompletionPercentage)%</div> + </div> + </div> + + <div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-200"> + <div class="h-full rounded-full bg-teal-700 dark:bg-teal-500" style="width: @progress.CompletionPercentage%"></div> + </div> + </div> + } + </div> + </div> + + <div class="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"> + <div class="flex items-start justify-between gap-4"> + <div> + <h3 class="text-xl font-bold text-slate-900">@AppLocalization.Text("dashboard.calendar", "Calendar")</h3> + <p class="mt-1 text-sm text-slate-500">@AppLocalization.Text("dashboard.calendarDesc", "Today and upcoming project starts.")</p> + </div> + <div class="text-right"> + <div class="text-sm font-semibold text-violet-600">@CalendarMonthLabel</div> + </div> + </div> + + <div class="mt-5 grid grid-cols-7 gap-2 text-center text-xs font-semibold uppercase tracking-wider text-slate-400"> + @foreach (var day in WeekdayLabels) + { + <span>@day</span> + } + </div> + + <div class="mt-3 grid grid-cols-7 gap-2"> + @foreach (var cell in CalendarCells) + { + <div class="@GetCalendarCellClasses(cell)"> + <div class="flex h-full flex-col justify-between"> + <div class="text-sm font-semibold @(cell.IsCurrentMonth ? "text-slate-900" : "text-slate-400")">@AppLocalization.Number(cell.Date.Day)</div> + <div class="flex items-center gap-1"> + @foreach (var dot in cell.ProjectDots) + { + <span class="h-1.5 w-1.5 rounded-full" style="background-color: @dot;"></span> + } + </div> + </div> + </div> + } + </div> + + <div class="mt-6 max-h-[360px] space-y-3 overflow-y-auto pr-1"> + <div class="text-xs font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("dashboard.events", "Events")</div> + @foreach (var item in UpcomingProjectStarts) + { + <div class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3"> + <span class="mt-1 h-8 w-1.5 rounded-full" style="background-color: @item.Color"></span> + <div class="min-w-0"> + <div class="truncate text-sm font-semibold text-slate-900">@Content.GetText(item.Name, item.NameMy)</div> + <div class="text-xs text-slate-500">@item.DateLabel</div> + </div> + </div> + } + </div> + </div> + </div> + + <div class="rounded-xl border border-slate-200 bg-white p-5 text-slate-900 shadow-sm shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> + <div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"> + <div> + <h3 class="text-xl font-bold text-slate-900">@AppLocalization.Text("dashboard.projectTimeline", "Project Timeline")</h3> + <p class="mt-1 text-sm text-slate-500">@AppLocalization.Text("dashboard.projectTimelineDesc", "Browse every project in a gantt view and adjust the month window below.")</p> + </div> + <a href="/projects/all" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-teal-700 shadow-sm transition-colors hover:bg-teal-50 dark:border-slate-700 dark:bg-slate-900 dark:text-teal-300 dark:hover:bg-slate-800"> + @AppLocalization.Text("dashboard.viewAll", "View all") + </a> + </div> + + <div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3"> + <div class="flex flex-col gap-1"> + <label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">@AppLocalization.Text("dashboard.from", "From")</label> + <select class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100" + value="@timelineStartMonthValue" + @onchange="OnTimelineStartMonthChanged" + aria-label="@AppLocalization.Text("dashboard.from", "From")"> + @foreach (var month in TimelineMonthOptions) + { + <option value="@month.ToString("yyyy-MM", System.Globalization.CultureInfo.InvariantCulture)">@GetTimelineMonthLabel(month)</option> + } + </select> + </div> + <div class="flex flex-col gap-1"> + <label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">@AppLocalization.Text("dashboard.to", "To")</label> + <select class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition-colors focus:border-violet-400 focus:ring-2 focus:ring-violet-100" + value="@timelineEndMonthValue" + @onchange="OnTimelineEndMonthChanged" + aria-label="@AppLocalization.Text("dashboard.to", "To")"> + @foreach (var month in TimelineMonthOptions) + { + <option value="@month.ToString("yyyy-MM", System.Globalization.CultureInfo.InvariantCulture)">@GetTimelineMonthLabel(month)</option> + } + </select> + </div> + <div class="pb-1 text-sm font-medium text-slate-500"> + @TimelineRangeLabel + </div> + </div> + + @if (TimelineProjects.Count > 0) + { + <div class="mt-4 max-h-[620px] overflow-auto pr-1"> + <div id="project-timeline-chart" class="min-w-[1100px] w-full"></div> + </div> + } + else + { + <div class="flex items-center justify-center py-16 text-slate-400 text-sm">@AppLocalization.Text("dashboard.noProjectsToDisplay", "No projects to display.")</div> + } + </div> +</div> + +@code { + private DashboardSummaryDto summary = new(); + private List<TaskStatusOverviewDto> taskStatuses = new(); + private List<ProjectProgressDto> projectProgress = new(); + private List<TaskDto> taskList = new(); + private List<UserDto> usersList = new(); + private List<ProjectDto> 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 IReadOnlyList<DateTime> TimelineMonthOptions => BuildTimelineMonthOptions(); + + private bool _dataLoaded = false; + private bool _shouldRenderCharts = false; + + private int? hoveredActivityIndex; + private int? hoveredSliceIndex; + + private string[] WeekdayLabels => AppLocalization.IsBurmeseCulture + ? ["တနင်္ဂနွေ", "တနင်္လာ", "အင်္ဂါ", "ဗုဒ္ဓဟူး", "ကြာသပတေး", "သောကြာ", "စနေ"] + : ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + private List<ActivityMonthStat> ActivityMonths => BuildActivityMonths(); + private List<string> 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<ActivityPoint> ActivityPoints => BuildActivityPoints(); + private List<DistributionSlice> DonutSlices => BuildDonutSlices(); + private List<DistributionLegendItem> DistributionLegend => BuildDistributionLegend(); + private List<ChartDataPoint> 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<DashboardMetric> SummaryMetrics => new() + { + new("users", UsersDeltaText, AppLocalization.Number(summary.TotalUsers), AppLocalization.Text("dashboard.totalUsers", "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, AppLocalization.Number(projectsList.Count), AppLocalization.Text("dashboard.totalProjects", "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, AppLocalization.Number(ActiveTasksCount), AppLocalization.Text("dashboard.activeTasks", "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, $"{AppLocalization.Number(CompletionRate)}%", AppLocalization.Text("dashboard.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 => GetMonthYearLabel(DateTime.Today); + private List<CalendarCell> CalendarCells => BuildCalendarCells(); + private List<CalendarEventItem> UpcomingProjectStarts => BuildUpcomingProjectStarts(); + private List<ProjectDto> FeaturedProjects => TimelineProjects.Take(5).ToList(); + private List<ProjectDto> ProjectListItems => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList(); + private List<ProjectDto> TimelineProjects => projectsList.OrderBy(p => p.StartDate).ThenByDescending(p => p.CreatedAt).ToList(); + private List<DateTime> TimelineMonths => BuildTimelineMonths(); + private string TimelineRangeLabel => TryGetTimelineRange(out var range) ? $"{GetMonthYearLabel(range.Start)} - {GetMonthYearLabel(range.End)}" : 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<Result<DashboardSummaryDto>>("Dashboard/summary", Serialization.CaseInsensitive); + if (summaryResult?.IsSuccess == true && summaryResult.Value != null) + { + summary = summaryResult.Value; + } + + var tasksOverviewResult = await client.GetFromJsonAsync<Result<List<TaskStatusOverviewDto>>>("Dashboard/tasks-overview", Serialization.CaseInsensitive); + if (tasksOverviewResult?.IsSuccess == true && tasksOverviewResult.Value != null) + { + taskStatuses = tasksOverviewResult.Value; + } + + var progressResult = await client.GetFromJsonAsync<Result<List<ProjectProgressDto>>>("Dashboard/project-progress", Serialization.CaseInsensitive); + if (progressResult?.IsSuccess == true && progressResult.Value != null) + { + projectProgress = progressResult.Value; + } + + taskList = await client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive) ?? new(); + usersList = await client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive) ?? new(); + projectsList = await client.GetFromJsonAsync<List<ProjectDto>>("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 = Content.GetText(p.Name, p.NameMy), + 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), + isBurmese = AppLocalization.IsBurmeseCulture + }); + } + } + 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 Content.GetUserFullName(user); + } + + if (string.Equals(currentUserName, "System Admin", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.Text("common.systemAdmin", "System Admin"); + } + + if (string.Equals(currentUserName, "User", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.Text("common.user", "User"); + } + + return currentUserName; + } + + private List<ActivityMonthStat> BuildActivityMonths() + { + var now = DateTime.Today; + var start = new DateTime(now.Year, now.Month, 1).AddMonths(-5); + var months = new List<ActivityMonthStat>(); + + 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<ActivityMonthStat> 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<string>(); + 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<ActivityMonthStat> 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<string>(); + 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<ActivityPoint> BuildActivityPoints() + { + var months = ActivityMonths; + var points = new List<ActivityPoint>(); + 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<DistributionSlice> 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; + var slices = new List<DistributionSlice>(); + var cumulativeArc = 0.0; + + for (var i = 0; i < rawCounts.Length; i++) + { + if (rawCounts[i] <= 0) + { + // Arc length 0 with a full-circumference gap is 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 string GetDistributionLabel(int index) => index switch + { + 0 => AppLocalization.Text("status.done", "Completed"), + 1 => AppLocalization.Text("status.inprogress", "In Progress"), + 2 => AppLocalization.Text("status.todo", "To Do"), + _ => AppLocalization.Text("status.overdue", "Overdue") + }; + + private List<DistributionLegendItem> BuildDistributionLegend() + { + return DonutSlices.Select(slice => new DistributionLegendItem(slice.Label, slice.Count, slice.Color)).ToList(); + } + + private List<CalendarCell> 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<CalendarCell>(); + 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<CalendarEventItem> BuildUpcomingProjectStarts() + { + return projectsList + .Where(project => project.StartDate.Date >= DateTime.Today) + .OrderBy(project => project.StartDate) + .Select(project => new CalendarEventItem(project.Name, project.NameMy, GetDisplayDateLabel(project.StartDate), GetProjectColor(project.Id))) + .ToList(); + } + + private List<DateTime> BuildTimelineMonths() + { + var featured = FeaturedProjects; + if (featured.Count == 0) + { + return new List<DateTime>(); + } + + 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<DateTime>(); + var current = start; + while (current <= end) + { + months.Add(current); + current = current.AddMonths(1); + } + + return months; + } + + private List<TimelineRow> BuildTimelineRows() + { + var months = TimelineMonths; + if (months.Count == 0) + { + return new List<TimelineRow>(); + } + + var first = months.First(); + var n = months.Count; + + 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, + project.NameMy, + $"{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 IReadOnlyList<DateTime> BuildTimelineMonthOptions() + { + var first = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1).AddMonths(-18); + var last = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1).AddMonths(36); + var projectDates = projectsList + .SelectMany(project => new[] { project.StartDate, project.EndDate }) + .Select(date => new DateTime(date.Year, date.Month, 1)); + + if (projectDates.Any()) + { + first = projectDates.Min() < first ? projectDates.Min() : first; + last = projectDates.Max() > last ? projectDates.Max() : last; + } + + return Enumerable.Range(0, Math.Max(1, ((last.Year - first.Year) * 12) + last.Month - first.Month + 1)) + .Select(offset => first.AddMonths(offset)) + .ToList(); + } + + private string GetTimelineMonthLabel(DateTime month) + { + if (!AppLocalization.IsBurmeseCulture) + { + return month.ToString("MMMM yyyy", System.Globalization.CultureInfo.InvariantCulture); + } + + var names = new[] { "ဇန်နဝါရီ", "ဖေဖော်ဝါရီ", "မတ်", "ဧပြီ", "မေ", "ဇွန်", "ဇူလိုင်", "သြဂုတ်", "စက်တင်ဘာ", "အောက်တိုဘာ", "နိုဝင်ဘာ", "ဒီဇင်ဘာ" }; + return $"{names[month.Month - 1]} {AppLocalization.Number(month.Year)}"; + } + + 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 + ? AppLocalization.Text("dashboard.upcoming", "Upcoming") + : AppLocalization.Text("dashboard.active", "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<string> + { + "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) + { + var localizedDelta = AppLocalization.Number(Math.Abs(delta)); + if (delta > 0) + { + return $"+{localizedDelta} {AppLocalization.Text("dashboard.thisMonth", "this month")}"; + } + + if (delta < 0) + { + return $"-{localizedDelta} {AppLocalization.Text("dashboard.thisMonth", "this month")}"; + } + + return $"{AppLocalization.Number(0)} {AppLocalization.Text("dashboard.thisMonth", "this month")}"; + } + + private string BuildCompletionDeltaText() + { + var current = GetCompletionRateForMonth(DateTime.Today); + var previous = GetCompletionRateForMonth(DateTime.Today.AddMonths(-1)); + var delta = current - previous; + var localizedDelta = AppLocalization.Number(Math.Abs(delta)); + + if (delta > 0) + { + return $"+{localizedDelta}% {AppLocalization.Text("dashboard.vsLastMonth", "vs last mo")}"; + } + + if (delta < 0) + { + return $"-{localizedDelta}% {AppLocalization.Text("dashboard.vsLastMonth", "vs last mo")}"; + } + + return $"{AppLocalization.Number(0)}% {AppLocalization.Text("dashboard.vsLastMonth", "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) => AppLocalization.IsBurmeseCulture + ? $"{AppLocalization.Number(date.Month)}လ" + : date.ToString("MMM"); + + private string GetDisplayDateLabel(DateTime date) => AppLocalization.IsBurmeseCulture + ? $"{AppLocalization.Number(date.Month)}လ {AppLocalization.Number(date.Day)}ရက်" + : date.ToString("MMM d"); + + private string GetMonthYearLabel(DateTime date) => AppLocalization.IsBurmeseCulture + ? $"{AppLocalization.Number(date.Year)} ခုနှစ် {AppLocalization.Number(date.Month)}လ" + : date.ToString("MMMM yyyy"); + + 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<string> ProjectDots); + private readonly record struct CalendarEventItem(string Name, string? NameMy, string DateLabel, string Color); + private readonly record struct TimelineRow(string Name, string? NameMy, 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..d4d911e6bb45275a55b29f62a91042b0b86c4789 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Dashboard/ManagerDashboard.razor @@ -0,0 +1,740 @@ +@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 + +<PageTitle>@AppLocalization.Text("dashboard.managerTitle", "Manager Dashboard")</PageTitle> + +<div class="min-h-screen bg-slate-50 px-4 py-6 text-slate-900 sm:px-6 lg:px-8 dark:bg-slate-950 dark:text-slate-50"> + <div class="mx-auto max-w-[1440px] space-y-6"> + <div class="rounded-xl border border-slate-200 bg-white px-8 py-8 shadow-sm"> + <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> + <div> + <p class="text-sm font-semibold uppercase tracking-[0.08em] text-violet-700">@AppLocalization.Text("dashboard.teamView", "Team View")</p> + <h2 class="mt-2 text-3xl font-bold tracking-tight text-slate-950">@AppLocalization.Text("dashboard.myProjects", "My Projects")</h2> + <p class="mt-3 max-w-2xl text-sm text-slate-600"> + @AppLocalization.Text("dashboard.managerIntro", "Track your team, project health, and weekly task progress in one place.") + @if (summary.PendingTasksCount > 0) + { + <span class="text-slate-500"> @AppLocalization.Text("dashboard.pendingTasksNotice", "{count} tasks are still pending.").Replace("{count}", AppLocalization.Number(summary.PendingTasksCount))</span> + } + </p> + </div> + + <div class="flex flex-wrap gap-3"> + <a href="/projects/all" class="inline-flex items-center rounded-2xl bg-violet-700 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-600"> + <i data-lucide="folder-kanban" class="mr-2 h-4 w-4"></i> + @AppLocalization.Text("dashboard.allProjects", "All Projects") + </a> + <a href="/reports/tasks" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 shadow-sm transition-colors hover:bg-slate-50"> + <i data-lucide="bar-chart-3" class="mr-2 h-4 w-4"></i> + @AppLocalization.Text("dashboard.taskReport", "Task Report") + </a> + </div> + </div> + </div> + + @if (isLoading) + { + <div class="rounded-xl border border-slate-200 bg-white px-6 py-12 text-center shadow-sm"> + <div class="mx-auto flex items-center justify-center gap-3 text-slate-500"> + <svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> + </svg> + <span class="text-sm font-medium">@AppLocalization.Text("dashboard.loadingDashboard", "Loading dashboard...")</span> + </div> + </div> + } + else if (!string.IsNullOrWhiteSpace(errorMessage)) + { + <div class="rounded-xl border border-rose-200 bg-rose-50 px-6 py-5 text-sm text-rose-700 shadow-sm"> + @errorMessage + </div> + } + else + { + <ManagerOverview Metrics="SummaryMetrics" /> + + <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(280px,1fr)]"> + <ChartCard Title="@AppLocalization.Text("dashboard.weeklyTaskProgress", "Weekly Task Progress")" Subtitle="@AppLocalization.Text("dashboard.weeklyTaskProgressDesc", "Completed vs pending tasks per week")" TitleClass="text-xl font-bold text-slate-950"> + <Actions> + <div class="text-sm text-slate-500">@WeeklyRangeLabel</div> + </Actions> + <ChildContent> + <BarChart Groups="WeeklyBarGroups" /> + </ChildContent> + </ChartCard> + + <ChartCard Title="@AppLocalization.Text("dashboard.teamProgress", "Team Progress")" Subtitle="@AppLocalization.Text("dashboard.teamProgressDesc", "Members with active workload")" TitleClass="text-xl font-bold text-slate-950"> + <ChildContent> + <div class="max-h-[460px] overflow-y-auto pr-1"> + <ProgressListChart Items="TeamProgressItems" EmptyText="@AppLocalization.Text("dashboard.noAssignedWork", "No assigned work yet.")" /> + </div> + </ChildContent> + </ChartCard> + </div> + + <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]"> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex items-start justify-between gap-4"> + <div> + <h3 class="text-xl font-bold text-slate-950">@CalendarMonthLabel</h3> + <p class="mt-1 text-sm text-slate-500">@CalendarTodayLabel</p> + </div> + </div> + + <div class="mt-5 grid grid-cols-7 gap-2 text-center text-xs font-semibold uppercase tracking-wider text-slate-400"> + @foreach (var day in WeekdayLabels) + { + <span>@day</span> + } + </div> + + <div class="mt-3 grid grid-cols-7 gap-2"> + @foreach (var cell in CalendarCells) + { + <div class="@GetCalendarCellClasses(cell)"> + <div class="flex h-full flex-col justify-between"> + <div class="text-sm font-semibold @(cell.IsCurrentMonth ? (cell.IsToday ? "text-white" : "text-slate-900") : "text-slate-400")">@AppLocalization.Number(cell.Date.Day)</div> + <div class="flex items-center gap-1"> + @foreach (var dot in cell.DeadlineDots) + { + <span class="h-1.5 w-1.5 rounded-full" style="background-color: @dot;"></span> + } + </div> + </div> + </div> + } + </div> + </div> + + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <h3 class="text-xl font-bold text-slate-950">@AppLocalization.Text("dashboard.upcomingDeadlines", "Upcoming Deadlines")</h3> + <p class="mt-1 text-sm text-slate-500">@AppLocalization.Text("dashboard.projectsEndingSoon", "Projects ending soon")</p> + + <div class="mt-6 max-h-[460px] space-y-3 overflow-y-auto pr-1"> + @if (UpcomingDeadlines.Any()) + { + @foreach (var item in UpcomingDeadlines) + { + <div class="flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3"> + <span class="h-2.5 w-2.5 rounded-full" style="background-color: @item.Color;"></span> + <div class="min-w-0 flex-1"> + <div class="truncate text-sm font-semibold text-slate-950">@Content.GetText(item.Name, item.NameMy)</div> + <div class="text-xs text-slate-500">@AppLocalization.LocalizeDigits(item.EndDateLabel)</div> + </div> + <div class="@item.DaysLeftClass">@item.DaysLeftLabel</div> + </div> + } + } + else + { + <div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500"> + @AppLocalization.Text("dashboard.noUpcomingDeadlines", "No upcoming deadlines.") + </div> + } + </div> + </div> + </div> + + <div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> + <div class="flex items-center justify-between gap-4 border-b border-slate-100 px-6 py-5"> + <div> + <h3 class="text-xl font-bold text-slate-950">@AppLocalization.Text("dashboard.myProjects", "My Projects")</h3> + <p class="mt-1 text-sm text-slate-500">@AppLocalization.Text("dashboard.clickViewTasks", "Click \"View Tasks\" to drill in")</p> + </div> + <a href="/projects/all" class="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-violet-700 shadow-sm transition-colors hover:bg-slate-50"> + @AppLocalization.Text("dashboard.viewAll", "View all") + </a> + </div> + + <div class="max-h-[760px] divide-y divide-slate-100 overflow-y-auto"> + @if (ProjectCards.Any()) + { + @foreach (var project in ProjectCards) + { + <div class="px-6 py-6 transition-colors hover:bg-slate-50"> + <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> + <div class="min-w-0 flex-1"> + <div class="flex flex-wrap items-center gap-3"> + <span class="h-2.5 w-2.5 rounded-full" style="background-color: @project.Color;"></span> + <h4 class="truncate text-lg font-semibold text-slate-950">@Content.GetText(project.Name, project.NameMy)</h4> + <span class="@project.StatusClass">@project.StatusLabel</span> + </div> + + <div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-slate-500"> + <span>@AppLocalization.Number(project.Completed)/@AppLocalization.Number(project.Total) @AppLocalization.Text("dashboard.tasksUnit", "tasks")</span> + <span class="text-rose-600">@AppLocalization.Number(project.Overdue) @AppLocalization.Text("status.overdue", "overdue")</span> + <span>@AppLocalization.Number(project.TeamSize) @AppLocalization.Text("dashboard.teamMembers", "members")</span> + </div> + + <div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200"> + <div class="h-full rounded-full transition-all" style="width: @project.ProgressPercentage%; background-color: @project.Color;"></div> + </div> + </div> + + <div class="flex flex-col items-start gap-3 lg:items-end"> + <div class="text-right"> + <div class="text-xl font-bold text-slate-950">@AppLocalization.Number(project.ProgressPercentage, "0")%</div> + <div class="text-xs uppercase tracking-[0.08em] text-slate-500">@AppLocalization.Text("dashboard.complete", "Complete")</div> + </div> + <button @onclick="() => ViewProjectTasks(project.Id)" class="inline-flex items-center rounded-2xl bg-violet-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-600"> + <i data-lucide="eye" class="mr-2 h-4 w-4"></i> + @AppLocalization.Text("dashboard.viewTasks", "View Tasks") + </button> + </div> + </div> + </div> + } + } + else + { + <div class="px-6 py-10 text-sm text-slate-500">@AppLocalization.Text("dashboard.noProjectsForAccount", "No projects available for this account.")</div> + } + </div> + </div> + } + </div> +</div> + +@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<ProjectDto> projects = new(); + private List<TaskDto> tasks = new(); + private List<UserDto> users = new(); + private List<ProjectProgressDto> projectProgress = new(); + private DashboardSummaryDto summary = new(); + + private int? hoveredWeekIndex; + + private List<WeeklyProgressViewModel> 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}" + : GetMonthYearLabel(DateTime.Today); + + private List<DashboardMetric> SummaryMetrics => BuildSummaryMetrics(); + private List<ProjectCardViewModel> ProjectCards => BuildProjectCards(); + private List<TeamProgressViewModel> TeamProgress => BuildTeamProgress(); + private List<ProgressListChartItem> TeamProgressItems => TeamProgress + .Select(member => new ProgressListChartItem( + member.FullName, + member.CompletionPercentage, + $"{AppLocalization.Number(member.Completed)}/{AppLocalization.Number(member.Total)}", + member.Color, + $"{member.Label} - {AppLocalization.Number(member.CompletionPercentage, "0")}%", + member.Initials)) + .ToList(); + private List<BarChartGroup> WeeklyBarGroups => WeeklyProgress + .Select(week => new BarChartGroup( + week.Label, + new[] + { + new ChartDataPoint(AppLocalization.Text("status.done", "Completed"), week.Completed, "#3b82f6"), + new ChartDataPoint(AppLocalization.Text("dashboard.pending", "Pending"), week.Pending, "#14b8a6") + }, + week.DateRangeLabel)) + .ToList(); + private List<YAxisTickViewModel> YTicks => BuildYAxisTicks(); + private List<CalendarCellViewModel> CalendarCells => BuildCalendarCells(); + private List<DeadlineItemViewModel> UpcomingDeadlines => BuildUpcomingDeadlines(); + private string[] WeekdayLabels => AppLocalization.IsBurmeseCulture + ? ["တနင်္ဂနွေ", "တနင်္လာ", "အင်္ဂါ", "ဗုဒ္ဓဟူး", "ကြာသပတေး", "သောကြာ", "စနေ"] + : ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + private string CalendarMonthLabel => GetMonthYearLabel(DateTime.Today); + private string CalendarTodayLabel => GetDisplayDateLabel(DateTime.Today); + 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<Result<DashboardSummaryDto>>("Dashboard/summary", Serialization.CaseInsensitive); + var progressTask = client.GetFromJsonAsync<Result<List<ProjectProgressDto>>>("Dashboard/project-progress", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive); + var tasksTask = client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync<List<UserDto>>("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<ProjectProgressDto>(); + } + + projects = (await projectsTask) ?? new List<ProjectDto>(); + tasks = (await tasksTask) ?? new List<TaskDto>(); + users = (await usersTask) ?? new List<UserDto>(); + } + 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<DashboardMetric> 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<DashboardMetric> + { + new("folder-kanban", AppLocalization.Text("dashboard.allActive", "all active"), AppLocalization.Number(activeProjects), AppLocalization.Text("dashboard.activeProjects", "Active Projects"), "rounded-2xl bg-violet-700 p-3 text-white", "text-sm font-semibold text-emerald-700"), + new("check-square", AppLocalization.Text("dashboard.acrossProjects", "across projects"), AppLocalization.Number(totalTasks), AppLocalization.Text("dashboard.totalTasksMetric", "Total Tasks"), "rounded-2xl bg-cyan-600 p-3 text-white", "text-sm font-semibold text-emerald-700"), + new("users", AppLocalization.Text("dashboard.yourTeam", "your team"), AppLocalization.Number(teamMembers), AppLocalization.Text("dashboard.teamMembers", "Team Members"), "rounded-2xl bg-emerald-700 p-3 text-white", "text-sm font-semibold text-emerald-700"), + new("triangle-alert", AppLocalization.Text("dashboard.needsAttention", "needs attention"), AppLocalization.Number(overdueTasks), AppLocalization.Text("dashboard.overdueTasks", "Overdue Tasks"), "rounded-2xl bg-rose-600 p-3 text-white", "text-sm font-semibold text-rose-600") + }; + } + + private List<ProjectCardViewModel> 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 + ? AppLocalization.Text("dashboard.atRisk", "At Risk") + : isUpcoming + ? AppLocalization.Text("dashboard.upcoming", "Upcoming") + : AppLocalization.Text("dashboard.active", "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, + project.NameMy, + completed, + total, + overdue, + teamSize, + percentage, + statusLabel, + statusClass, + palette[index % palette.Length]); + }) + .ToList(); + } + + private List<TeamProgressViewModel> 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 displayName = Content.GetUserFullName(user); + var initials = AppLocalization.IsBurmeseCulture ? BuildInitialsFromDisplayName(displayName) : BuildInitials(user.FirstName, user.LastName); + var label = $"{AppLocalization.Number(total)} {AppLocalization.Text("dashboard.tasksUnit", "tasks")}"; + + return new TeamProgressViewModel( + user.Id, + displayName, + initials, + completed, + total, + percentage, + label, + palette[index % palette.Length]); + }) + .Where(item => item is not null) + .Cast<TeamProgressViewModel>() + .OrderByDescending(item => item.Total) + .ThenBy(item => item.FullName) + .ToList(); + } + + private List<CalendarCellViewModel> 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<CalendarCellViewModel>(); + 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<string>())); + } + + return cells; + } + + private List<DeadlineItemViewModel> 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 + ? AppLocalization.Text("dashboard.today", "Today") + : AppLocalization.Text("dashboard.daysLeft", "{count}d left").Replace("{count}", AppLocalization.Number(daysLeft)); + var labelClass = daysLeft <= 14 ? "text-amber-500 font-semibold" : "text-slate-500"; + + return new DeadlineItemViewModel( + project.Id, + project.Name, + project.NameMy, + project.EndDate.ToString("yyyy-MM-dd"), + label, + labelClass, + GetProjectColor(project.Id)); + }) + .ToList(); + } + + private List<WeeklyProgressViewModel> 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<WeeklyProgressViewModel>(); + 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( + GetWeekLabel(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<YAxisTickViewModel> 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<YAxisTickViewModel> + { + 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 $"{AppLocalization.MonthName(start, true)} {AppLocalization.Number(start.Day)} - {AppLocalization.Number(end.Day)}"; + } + + return $"{AppLocalization.MonthName(start, true)} {AppLocalization.Number(start.Day)} - {AppLocalization.MonthName(end, true)} {AppLocalization.Number(end.Day)}"; + } + + private static string GetWeekLabel(int index) => AppLocalization.IsBurmeseCulture + ? AppLocalization.Number(index) + : $"W{index}"; + + 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 static string BuildInitialsFromDisplayName(string displayName) + { + var parts = displayName + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length >= 2) + { + return $"{parts[0][0]}{parts[^1][0]}"; + } + + return displayName.Length > 1 ? displayName[..2] : displayName; + } + + private string GetProjectColor(long projectId) + { + var palette = new[] + { + "#2563eb", + "#0ea5e9", + "#10b981", + "#f59e0b", + "#f97316" + }; + + return palette[(int)(Math.Abs(projectId) % palette.Length)]; + } + + private string GetDisplayDateLabel(DateTime date) => AppLocalization.IsBurmeseCulture + ? $"{AppLocalization.MonthName(date, true)} {AppLocalization.Number(date.Day)}" + : date.ToString("dddd, d"); + + private string GetMonthYearLabel(DateTime date) => AppLocalization.IsBurmeseCulture + ? $"{AppLocalization.Number(date.Year)} {AppLocalization.MonthName(date)}" + : date.ToString("MMMM yyyy"); + + private string GetCalendarCellClasses(CalendarCellViewModel cell) + { + var classes = new List<string> { "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, + string? NameMy, + 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<string> DeadlineDots); + + private sealed record DeadlineItemViewModel( + long ProjectId, + string Name, + string? NameMy, + 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..56e970c40f484f042f7fa9e68852a4374bb81e3e --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/AddIssue.razor @@ -0,0 +1,639 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("addIssue", "Add Issue")</PageTitle> + +<div class="space-y-6"> + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("common.addIssue", "Add Issue")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("issue.addIssueDescription", "Create a one-day work issue for a task and record the hours you spent.")</p> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingTasksAndUsers", "Loading tasks and users...")" /> + } + else + { + <div class="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div class="md:col-span-2"> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.task", "Task") <span class="text-red-500">*</span></label> + <select value="@formTaskId" @onchange="OnTaskChanged" class="w-full rounded-lg border @(isTaskError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-violet-600") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.selectTask", "Select task...")</option> + @foreach (var task in tasks) + { + <option value="@task.Id">@Content.GetTaskTitle(task)</option> + } + </select> + @if (isTaskError) + { + <p class="mt-1 text-xs text-red-500">@taskErrorMessage</p> + } + </div> + + <div class="md:col-span-2"> + <BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formTitleMy))" + EnglishComplete="@(!string.IsNullOrWhiteSpace(formTitle))" + BurmeseAiGenerated="@burmeseGenerated" + EnglishAiGenerated="@englishGenerated" + BurmeseNeedsReview="@burmeseNeedsReview" + EnglishNeedsReview="@englishNeedsReview"> + <BurmeseContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">ခေါင်းစဉ် <span class="text-red-500">*</span></label> + <input @bind="formTitleMy" type="text" placeholder="ခေါင်းစဉ်" class="w-full rounded-lg border @(isTitleError && string.IsNullOrWhiteSpace(formTitle) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formTitleMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueTitleToEnglish" /> + <BilingualTranslationAction SourceText="@formTitleMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitle))" Generated="GenerateIssueEnglishTitle" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">ဖော်ပြချက်</label> + <textarea @bind="formDescriptionMy" rows="3" placeholder="ဖော်ပြချက်" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescriptionMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueDescriptionToEnglish" /> + <BilingualTranslationAction SourceText="@formDescriptionMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescription))" Generated="GenerateIssueEnglishDescription" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">နှောင့်နှေးရခြင်းအကြောင်း</label> + <input @bind="formDelayReasonMy" type="text" placeholder="နှောင့်နှေးရခြင်းအကြောင်း" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formDelayReasonMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueDelayToEnglish" /> + <BilingualTranslationAction SourceText="@formDelayReasonMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDelayReason))" Generated="GenerateIssueEnglishDelay" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">ပိတ်ဆို့ထားသူ / မှီခိုမှု</label> + <input @bind="formBlockedByMy" type="text" placeholder="ပိတ်ဆို့ထားသူ / မှီခိုမှု" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formBlockedByMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueBlockedByToEnglish" /> + <BilingualTranslationAction SourceText="@formBlockedByMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formBlockedBy))" Generated="GenerateIssueEnglishBlockedBy" /> + </BurmeseContent> + <EnglishContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("field.title", "Title") <span class="text-red-500">*</span></label> + <input @bind="formTitle" type="text" placeholder="@AppLocalization.Text("issue.whatDidYouWorkOn", "What did you work on?")" class="w-full rounded-lg border @(isTitleError && string.IsNullOrWhiteSpace(formTitleMy) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formTitle" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueTitleToBurmese" /> + <BilingualTranslationAction SourceText="@formTitle" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitleMy))" Generated="GenerateIssueBurmeseTitle" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label> + <textarea @bind="formDescription" rows="3" placeholder="@AppLocalization.Text("issue.addShortNote", "Add a short note about the work you did...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescription" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueDescriptionToBurmese" /> + <BilingualTranslationAction SourceText="@formDescription" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescriptionMy))" Generated="GenerateIssueBurmeseDescription" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.delayReason", "Delay Reason")</label> + <input @bind="formDelayReason" type="text" placeholder="@AppLocalization.Text("issue.delayReasonPlaceholder", "Why is this issue delayed?")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formDelayReason" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueDelayToBurmese" /> + <BilingualTranslationAction SourceText="@formDelayReason" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDelayReasonMy))" Generated="GenerateIssueBurmeseDelay" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.blockedBy", "Blocked By")</label> + <input @bind="formBlockedBy" type="text" placeholder="@AppLocalization.Text("issue.blockedByPlaceholder", "Dependency, person, or team")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formBlockedBy" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueBlockedByToBurmese" /> + <BilingualTranslationAction SourceText="@formBlockedBy" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formBlockedByMy))" Generated="GenerateIssueBurmeseBlockedBy" /> + </EnglishContent> + </BilingualEditorTabs> + @if (isTitleError) { <p class="mt-1 text-xs text-red-500">@titleErrorMessage</p> } + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.estimatedHours", "Estimated Hours")</label> + <input @bind="formEstimatedHours" type="number" step="0.25" min="0" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" + placeholder="@AppLocalization.Text("issue.exampleEstimatedHours", "e.g. 4")" /> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.actualHours", "Actual Hours")</label> + <input @bind="formActualHours" type="number" step="0.25" min="0" max="7" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" + placeholder="@AppLocalization.Text("issue.exampleActualHours", "e.g. 7")" /> + <p class="mt-1 text-xs text-slate-500">@AppLocalization.Text("issue.actualHoursHint", "Use the total hours you spent today. For your standard day, this is usually 7 hours.")</p> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date")</label> + <input @bind="formStartDate" type="date" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.dueDate", "Due Date")</label> + <input @bind="formDueDate" type="date" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.status", "Status")</label> + <select @bind="formStatusId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="@AppTaskStatus.Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="@AppTaskStatus.InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="@AppTaskStatus.Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + </select> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.priority", "Priority")</label> + <select @bind="formPriorityId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option> + <option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option> + <option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option> + </select> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.blockedStatus", "Blocked Status")</label> + <label class="flex h-10 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-700"> + <input type="checkbox" @bind="formIsBlocked" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("status.blocked", "Blocked")</span> + </label> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.escalationLevel", "Escalation Level")</label> + <select @bind="formEscalationLevel" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.none", "None")</option> + <option value="1">@AppLocalization.Text("issue.teamLead", "Team Lead")</option> + <option value="2">@AppLocalization.Text("issue.pm", "PM")</option> + <option value="3">@AppLocalization.Text("issue.manager", "Manager")</option> + </select> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.project", "Project")</label> + <input value="@SelectedProjectLabel" readonly + class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 focus:outline-none" /> + </div> + + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.assignee", "Assignee")</label> + <div class="relative"> + <button type="button" + @onclick="ToggleAssigneeDropdown" + class="flex h-11 w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-3 text-left text-sm text-slate-900 shadow-sm transition-colors hover:border-violet-300 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-violet-600"> + <span class="truncate @(formAssignedTo.HasValue ? "font-medium text-slate-900" : "text-slate-400")">@SelectedAssigneeLabel</span> + <i data-lucide="chevron-down" class="ml-3 h-4 w-4 shrink-0 text-slate-400"></i> + </button> + + @if (isAssigneeDropdownOpen) + { + <div class="absolute bottom-full left-0 z-30 mb-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-lg"> + <div class="flex items-center justify-end"> + <button type="button" + @onclick="CloseAssigneeDropdown" + class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600" + aria-label="@AppLocalization.Text("common.close", "Close")"> + <i data-lucide="x" class="h-4 w-4"></i> + </button> + </div> + <div class="relative"> + <input @bind="assigneeSearchInput" @bind:event="oninput" + @onfocus="EnsureAssigneeDropdownOpen" + type="text" + placeholder="@AppLocalization.Text("common.searchProjectMembers", "Search project members...")" + class="w-full rounded-xl border border-slate-200 bg-white py-2 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" /> + <i data-lucide="search" class="absolute left-3 top-2.5 h-4 w-4 text-slate-400"></i> + </div> + + @if (isLoadingProjectMembers) + { + <div class="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500"> + @AppLocalization.Text("common.loadingProjectMembers", "Loading project members...") + </div> + } + else + { + <div class="mt-3 max-h-56 overflow-y-auto space-y-2 pr-1"> + <button type="button" + @onclick="() => SetFormAssignee(null)" + class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(formAssignedTo.HasValue ? "border-slate-200 bg-white hover:bg-slate-50" : "border-violet-600 bg-violet-50/40")"> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 text-xs font-bold shrink-0"> + <i data-lucide="user-minus" class="h-4 w-4"></i> + </span> + <div class="min-w-0 flex-1"> + <p class="text-sm font-semibold text-slate-900">@AppLocalization.Text("common.unassigned", "Unassigned")</p> + </div> + </button> + + @foreach (var user in FilteredProjectMembers) + { + var isSelected = formAssignedTo == user.Id; + <button type="button" + @onclick="() => SetFormAssignee(user.Id)" + class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(isSelected ? "border-violet-600 bg-violet-50/40" : "border-slate-100 bg-white hover:border-slate-200 hover:bg-slate-50")"> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0"> + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + </span> + <div class="min-w-0 flex-1"> + <p class="truncate text-sm font-semibold text-slate-900">@Content.GetUserFullName(user)</p> + <p class="truncate text-xs text-slate-500">@@@user.Username</p> + </div> + </button> + } + + @if (!FilteredProjectMembers.Any()) + { + <div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500"> + @AppLocalization.Text("common.noMembersMatchSearch", "No members match the current search.") + </div> + } + </div> + } + </div> + } + </div> + </div> + </div> + + <div class="mt-6 flex items-center justify-end gap-3"> + <button @onclick="ResetForm" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + <button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-semibold text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm"> + @(isSaving ? AppLocalization.Text("common.loadingSpinner", "Loading...") : AppLocalization.Text("common.addIssue", "Add Issue")) + </button> + </div> + + @if (!string.IsNullOrWhiteSpace(errorMessage)) + { + <p class="mt-4 text-sm text-red-600">@errorMessage</p> + } + </div> + } +</div> + +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@AppLocalization.Text("common.addIssueTitle", "Add Issue?")" + Message="@AppLocalization.Text("issue.saveAddConfirmMessage", "Are you sure you want to save this issue?")" + ConfirmText="@AppLocalization.Text("issue.yesAdd", "Yes, Add")" + Icon="circle-plus" + IconBgClass="bg-emerald-50" + IconTextClass="text-emerald-600" + ButtonClass="bg-violet-600 hover:bg-violet-700" + OnConfirm="ConfirmSave" + OnCancel="CloseConfirmDialog" /> + +@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 List<TaskDto> tasks = new(); + private List<ProjectDto> projects = new(); + private List<UserDto> projectMembers = new(); + private long currentUserId; + + private long formTaskId; + private string formTitle = ""; + private string? formTitleMy; + private string? formDescription; + private string? formDescriptionMy; + private bool burmeseGenerated; + private bool englishGenerated; + private bool burmeseNeedsReview; + private bool englishNeedsReview; + 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 string? formDelayReasonMy; + private bool formIsBlocked; + private string? formBlockedBy; + private string? formBlockedByMy; + private int formEscalationLevel; + private string assigneeSearchInput = ""; + private bool isAssigneeDropdownOpen; + private bool isLoadingProjectMembers; + + private void MoveIssueTitleToEnglish() { formTitle = formTitleMy ?? string.Empty; } + private void MoveIssueTitleToBurmese() { formTitleMy = formTitle; } + private void MoveIssueDescriptionToEnglish() { formDescription = formDescriptionMy; } + private void MoveIssueDescriptionToBurmese() { formDescriptionMy = formDescription; } + private void MoveIssueDelayToEnglish() { formDelayReason = formDelayReasonMy; } + private void MoveIssueDelayToBurmese() { formDelayReasonMy = formDelayReason; } + private void MoveIssueBlockedByToEnglish() { formBlockedBy = formBlockedByMy; } + private void MoveIssueBlockedByToBurmese() { formBlockedByMy = formBlockedBy; } + private void MarkGenerated(bool english) + { + if (english) { englishGenerated = true; englishNeedsReview = true; } + else { burmeseGenerated = true; burmeseNeedsReview = true; } + } + private Task GenerateIssueEnglishTitle(TranslationResult result) { formTitle = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseTitle(TranslationResult result) { formTitleMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateIssueEnglishDescription(TranslationResult result) { formDescription = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseDescription(TranslationResult result) { formDescriptionMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateIssueEnglishDelay(TranslationResult result) { formDelayReason = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseDelay(TranslationResult result) { formDelayReasonMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateIssueEnglishBlockedBy(TranslationResult result) { formBlockedBy = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseBlockedBy(TranslationResult result) { formBlockedByMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + + private TaskDto? SelectedTask => tasks.FirstOrDefault(t => t.Id == formTaskId); + private string SelectedTaskLabel => SelectedTask == null ? AppLocalization.Text("common.noTaskSelectedForIssue", "No task selected") : Content.GetTaskTitle(SelectedTask); + private string SelectedProjectLabel => SelectedTask == null + ? AppLocalization.Text("common.noProject", "No project") + : (projects.FirstOrDefault(p => p.Id == SelectedTask.ProjectId) is { } project ? Content.GetProjectName(project) : $"{AppLocalization.Text("common.project", "Project")} #{AppLocalization.Number(SelectedTask.ProjectId)}"); + private string SelectedAssigneeLabel => formAssignedTo.HasValue + ? (projectMembers.FirstOrDefault(u => u.Id == formAssignedTo.Value) is { } user ? Content.GetUserFullName(user) : AppLocalization.Text("common.selectedMember", "Selected member")) + : AppLocalization.Text("common.unassigned", "Unassigned"); + + private IEnumerable<UserDto> 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} {u.FirstNameMy} {u.LastNameMy}".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<List<TaskDto>>("Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync<List<UserDto>>("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) + { + Console.WriteLine($"Load issue data error: {ex.Message}"); + errorMessage = AppLocalization.Text("common.loadFailed", "Unable to load the requested data. Please try again."); + } + 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<Result<IEnumerable<UserDto>>>($"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 = AppLocalization.Text("validation.selectTaskRequired", "Please select a task."); + return; + } + + if (string.IsNullOrWhiteSpace(formTitle) && string.IsNullOrWhiteSpace(formTitleMy)) + { + isTitleError = true; + titleErrorMessage = AppLocalization.Text("validation.issueTitleRequired", "Issue title is required."); + return; + } + + if (formDueDate.Date < formStartDate.Date) + { + errorMessage = AppLocalization.Text("issue.dateRangeInvalid", "The due date cannot be earlier than the start date."); + return; + } + + showConfirmDialog = true; + } + + private void CloseConfirmDialog() + { + showConfirmDialog = false; + } + + private async Task ConfirmSave() + { + showConfirmDialog = false; + await SaveIssueAsync(); + } + + private async Task SaveIssueAsync() + { + if (isSaving) + { + return; + } + + if (formTaskId <= 0) + { + errorMessage = AppLocalization.Text("validation.selectTaskRequired", "Please select a task."); + return; + } + + if (string.IsNullOrWhiteSpace(formTitle) && string.IsNullOrWhiteSpace(formTitleMy)) + { + errorMessage = AppLocalization.Text("validation.issueTitleRequired", "Issue title is required."); + return; + } + + if (formDueDate.Date < formStartDate.Date) + { + errorMessage = AppLocalization.Text("issue.dateRangeInvalid", "The due date cannot be earlier than the start date."); + return; + } + + isSaving = true; + errorMessage = null; + + try + { + var client = ApiClient.CreateClient(); + var dto = new CreateIssueDto + { + TaskId = formTaskId, + Title = formTitle.Trim(), + TitleMy = formTitleMy, + Description = formDescription, + DescriptionMy = formDescriptionMy, + AssignedTo = formAssignedTo, + EstimatedHours = formEstimatedHours, + ActualHours = formActualHours, + DelayReason = formDelayReason, + DelayReasonMy = formDelayReasonMy, + IsBlocked = formIsBlocked, + BlockedBy = formBlockedBy, + BlockedByMy = formBlockedByMy, + EscalationLevel = formEscalationLevel, + StartDate = formStartDate, + DueDate = formDueDate, + StatusId = formStatusId, + PriorityId = formPriorityId + }; + + var response = await client.PostAsJsonAsync("Issue", dto); + var result = await response.Content.ReadFromJsonAsync<Result<IssueDto>>(Serialization.CaseInsensitive); + + if (response.IsSuccessStatusCode && result?.IsSuccess == true) + { + Navigation.NavigateTo("/issues/all?created=true"); + } + else + { + errorMessage = AppLocalization.Text("issue.failedCreate", "Failed to create issue. Please try again."); + Console.WriteLine($"Create issue rejected: {result?.ErrorMessage ?? response.ReasonPhrase}"); + } + } + catch (Exception ex) + { + errorMessage = AppLocalization.Text("issue.failedCreate", "Failed to create issue. Please try again."); + Console.WriteLine($"Create issue error: {ex.Message}"); + } + finally + { + isSaving = false; + } + } + + private void ResetForm() + { + formTaskId = tasks.FirstOrDefault()?.Id ?? 0; + formTitle = ""; + formTitleMy = null; + formDescription = null; + formDescriptionMy = null; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + formAssignedTo = currentUserId; + formEstimatedHours = null; + formActualHours = 7m; + formStartDate = DateTime.Today; + formDueDate = DateTime.Today; + formStatusId = AppTaskStatus.Todo; + formPriorityId = TaskPriority.Medium; + formDelayReason = null; + formDelayReasonMy = null; + formIsBlocked = false; + formBlockedBy = null; + formBlockedByMy = 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..4244585b75a21e5f78cb381b7e8cf098c6a0f578 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Issues/Issues.razor @@ -0,0 +1,921 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("issues", "Issues")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("issues", "Issues")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("page.issues.desc", "Track daily work items, update progress, and review what was done.")</p> + </div> + @if (canCreateIssue) + { + <button @onclick="GoToAddIssue" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="plus" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("common.addIssue", "Add Issue") + </button> + } + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingIssues", "Loading issues...")" /> + } + else + { + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"> + <div class="grid grid-cols-1 lg:grid-cols-6 gap-3"> + <div class="lg:col-span-2"> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchIssues", "Search issue title, description or assignee...")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <select @bind="taskIdInput" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allTasks", "All tasks")</option> + @foreach (var task in tasks) + { + <option value="@task.Id">@Content.GetTaskTitle(task)</option> + } + </select> + </div> + <div> + <select @bind="projectIdInput" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allProjects", "All projects")</option> + @foreach (var project in projects) + { + <option value="@project.Id">@Content.GetProjectName(project)</option> + } + </select> + </div> + <div> + <select @bind="statusIdInput" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="">@AppLocalization.Text("common.all", "All")</option> + <option value="@AppTaskStatus.Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="@AppTaskStatus.InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="@AppTaskStatus.Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + </select> + </div> + <div> + <select @bind="priorityIdInput" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="">@AppLocalization.Text("common.all", "All")</option> + <option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option> + <option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option> + <option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option> + </select> + </div> + </div> + + <div class="mt-3 flex flex-wrap items-center gap-3"> + <label class="inline-flex items-center gap-2 text-sm text-slate-700"> + <input type="checkbox" @bind="assignedOnly" class="h-4 w-4 rounded border-slate-300 accent-violet-600" /> + @AppLocalization.Text("common.assignedToMeOnly", "Assigned to me only") + </label> + <button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + </div> + </div> + + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <div class="overflow-hidden"> + <table class="w-full table-fixed"> + <colgroup> + <col class="w-[12%]" /> + <col class="w-[24%]" /> + <col class="w-[11%]" /> + <col class="w-[11%]" /> + <col class="w-[12%]" /> + <col class="w-[12%]" /> + <col class="w-[12%]" /> + <col class="w-[8%]" /> + <col class="w-[10%]" /> + </colgroup> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.taskCode", "Task Code")</th> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.issue", "Issue")</th> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.hours", "Hours")</th> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.status", "Status")</th> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.priority", "Priority")</th> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.date", "Date")</th> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.assignee", "Assignee")</th> + <th class="px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.risk", "Risk")</th> + <th class="px-5 py-3 text-center text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actions", "Actions")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100 text-[13px]"> + @if (issues.Any()) + { + @foreach (var entry in issues.Select((issue, index) => (issue, index))) + { + var issue = entry.issue; + <tr class="hover:bg-slate-50/50 transition-colors align-top"> + <td class="px-5 py-4 text-left whitespace-nowrap"> + <span class="font-mono text-[11px] font-semibold tracking-wide text-indigo-700"> + @GetTaskCode(issue.TaskId) + </span> + </td> + <td class="px-5 py-4 text-left"> + <button @onclick="() => OpenEditModal(issue)" class="min-w-0 text-left text-sm font-semibold leading-5 text-slate-900 hover:text-violet-600 transition-colors break-words"> + @Content.GetIssueTitle(issue) + </button> + </td> + <td class="px-5 py-4 text-slate-600 whitespace-nowrap"> + <span class="font-medium text-slate-700">@(issue.ActualHours.HasValue ? AppLocalization.Number(issue.ActualHours.Value) : "-") @AppLocalization.Text("common.hoursAbbrev", "hr")</span> + </td> + <td class="px-5 py-4"> + <span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium @GetStatusBadge(issue.StatusId)"> + @GetStatusLabel(issue.StatusId) + </span> + </td> + <td class="px-5 py-4"> + <span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium @GetPriorityBadge(issue.PriorityId)"> + @GetPriorityLabel(issue.PriorityId) + </span> + </td> + <td class="px-5 py-4 text-sm text-slate-600 whitespace-nowrap"> + <span class="font-medium text-slate-700">@DisplayFormats.Date(issue.DueDate)</span> + </td> + <td class="px-5 py-4 text-sm text-slate-600 break-words"> + @(!string.IsNullOrWhiteSpace(Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)) ? Content.GetText(issue.AssignedToName, issue.AssignedToNameMy) : AppLocalization.Text("common.unassigned", "Unassigned")) + </td> + <td class="px-5 py-4"> + @if (issue.IsBlocked) + { + <span class="inline-flex items-center rounded-full bg-rose-50 px-2 py-0.5 text-xs font-semibold text-rose-700">@AppLocalization.Text("status.blocked", "Blocked")</span> + } + else if (issue.EscalationLevel > 0) + { + <span class="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">@GetEscalationLabel(issue.EscalationLevel)</span> + } + else + { + <span class="text-xs text-slate-400">@AppLocalization.Text("status.clear", "Clear")</span> + } + </td> + <td class="px-5 py-4 text-center"> + <div class="flex items-center justify-center gap-1"> + @if (canEditIssue) + { + <button @onclick="() => OpenEditModal(issue)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="@AppLocalization.Text("action.edit", "Edit")"> + <i data-lucide="pencil" class="h-4 w-4"></i> + </button> + } + @if (canDeleteIssue) + { + <button @onclick="() => RequestDeleteConfirmation(issue.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="@AppLocalization.Text("action.delete", "Delete")"> + <i data-lucide="trash-2" class="h-4 w-4"></i> + </button> + } + </div> + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="9" Icon="file-pen-line" Title="@AppLocalization.Text("common.noResults", "No results found.")" Subtitle="@AppLocalization.Text("common.createFirstEntry", "Create your first entry to get started.")" /> + } + </tbody> + </table> + </div> + + <Pagination CurrentPage="currentPage" + TotalPages="totalPages" + PageSize="pageSize" + TotalCount="totalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } +</div> + +@if (showModal) +{ + <div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm sm:p-6"> + <div class="flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl sm:max-h-[calc(100vh-3rem)]"> + <div class="shrink-0 flex items-center justify-between px-6 py-4 border-b border-slate-100"> + <div> + <h3 class="text-lg font-semibold text-slate-900">@AppLocalization.Text("common.editIssue", "Edit Issue")</h3> + <p class="text-xs text-slate-500 mt-0.5">#@AppLocalization.Number(editingId)</p> + </div> + <button @onclick="CloseModal" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"> + <i data-lucide="x" class="h-5 w-5"></i> + </button> + </div> + + <div class="min-h-0 flex-1 overflow-y-auto p-6 space-y-4"> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div class="rounded-xl border border-slate-200 bg-slate-50 p-3"> + <p class="text-xs font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("common.task", "Task")</p> + <p class="mt-1 text-sm font-semibold text-slate-900">@SelectedTaskLabel</p> + </div> + <div class="rounded-xl border border-slate-200 bg-slate-50 p-3"> + <p class="text-xs font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("common.project", "Project")</p> + <p class="mt-1 text-sm font-semibold text-slate-900">@SelectedProjectLabel</p> + </div> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.blockedStatus", "Blocked Status")</label> + <label class="flex h-10 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-700"> + <input type="checkbox" @bind="formIsBlocked" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("status.blocked", "Blocked")</span> + </label> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.escalationLevel", "Escalation Level")</label> + <select @bind="formEscalationLevel" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.none", "None")</option> + <option value="1">@AppLocalization.Text("issue.teamLead", "Team Lead")</option> + <option value="2">@AppLocalization.Text("issue.pm", "PM")</option> + <option value="3">@AppLocalization.Text("issue.manager", "Manager")</option> + </select> + </div> + </div> + + <BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formTitleMy) || !string.IsNullOrWhiteSpace(formDescriptionMy))" + EnglishComplete="@(!string.IsNullOrWhiteSpace(formTitle) || !string.IsNullOrWhiteSpace(formDescription))" + BurmeseAiGenerated="@burmeseGenerated" EnglishAiGenerated="@englishGenerated" + BurmeseNeedsReview="@burmeseNeedsReview" EnglishNeedsReview="@englishNeedsReview"> + <BurmeseContent> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">ပိတ်ဆို့မှု</label> + <input @bind="formBlockedByMy" type="text" placeholder="ပိတ်ဆို့ထားသူ / မှီခိုမှု" class="mb-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formBlockedByMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueBlockedByToEnglish" /> + <BilingualTranslationAction SourceText="@formBlockedByMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formBlockedBy))" Generated="GenerateIssueEnglishBlockedBy" /> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">နှောင့်နှေးရခြင်းအကြောင်း</label> + <input @bind="formDelayReasonMy" type="text" placeholder="နှောင့်နှေးရခြင်းအကြောင်း" class="mb-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formDelayReasonMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueDelayToEnglish" /> + <BilingualTranslationAction SourceText="@formDelayReasonMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDelayReason))" Generated="GenerateIssueEnglishDelay" /> + </div> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">ခေါင်းစဉ် <span class="text-red-500">*</span></label> + <input @bind="formTitleMy" type="text" placeholder="ခေါင်းစဉ်" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formTitleMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueTitleToEnglish" /> + <BilingualTranslationAction SourceText="@formTitleMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitle))" Generated="GenerateIssueEnglishTitle" /> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">ဖော်ပြချက်</label> + <textarea @bind="formDescriptionMy" rows="3" placeholder="ဖော်ပြချက်" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescriptionMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveIssueDescriptionToEnglish" /> + <BilingualTranslationAction SourceText="@formDescriptionMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescription))" Generated="GenerateIssueEnglishDescription" /> + </div> + </BurmeseContent> + <EnglishContent> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.blockedBy", "Blocked By")</label> + <input @bind="formBlockedBy" type="text" placeholder="@AppLocalization.Text("issue.blockedByPlaceholder", "Dependency, person, or team")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formBlockedBy" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueBlockedByToBurmese" /> + <BilingualTranslationAction SourceText="@formBlockedBy" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formBlockedByMy))" Generated="GenerateIssueBurmeseBlockedBy" /> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.delayReason", "Delay Reason")</label> + <input @bind="formDelayReason" type="text" placeholder="@AppLocalization.Text("issue.delayReasonPlaceholder", "Why is this issue delayed?")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formDelayReason" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueDelayToBurmese" /> + <BilingualTranslationAction SourceText="@formDelayReason" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDelayReasonMy))" Generated="GenerateIssueBurmeseDelay" /> + </div> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("field.title", "Title") <span class="text-red-500">*</span></label> + <input @bind="formTitle" type="text" placeholder="@AppLocalization.Text("issue.titlePlaceholder", "Issue title")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formTitle" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueTitleToBurmese" /> + <BilingualTranslationAction SourceText="@formTitle" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitleMy))" Generated="GenerateIssueBurmeseTitle" /> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label> + <textarea @bind="formDescription" rows="3" placeholder="@AppLocalization.Text("issue.descriptionPlaceholder", "Describe the work...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescription" ExpectedBurmese="false" MoveToOtherLanguage="MoveIssueDescriptionToBurmese" /> + <BilingualTranslationAction SourceText="@formDescription" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescriptionMy))" Generated="GenerateIssueBurmeseDescription" /> + </div> + </EnglishContent> + </BilingualEditorTabs> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.assignee", "Assignee")</label> + <select @bind="formAssignedTo" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="">@AppLocalization.Text("common.unassigned", "Unassigned")</option> + @foreach (var user in filteredProjectMembers) + { + <option value="@user.Id">@Content.GetUserFullName(user)</option> + } + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.actualHours", "Actual Hours")</label> + <input @bind="formActualHours" type="number" step="0.25" min="0" max="7" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date")</label> + <input @bind="formStartDate" type="date" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.dueDate", "Due Date")</label> + <input @bind="formDueDate" type="date" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.status", "Status")</label> + <select @bind="formStatusId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="@AppTaskStatus.Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="@AppTaskStatus.InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="@AppTaskStatus.Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.priority", "Priority")</label> + <select @bind="formPriorityId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option> + <option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option> + <option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option> + </select> + </div> + </div> + + @if (!string.IsNullOrWhiteSpace(errorMessage)) + { + <div class="rounded-lg border border-rose-100 bg-rose-50 p-3"> + <p class="text-sm text-rose-600">@errorMessage</p> + </div> + } + </div> + + <div class="shrink-0 flex items-center justify-end gap-3 border-t border-slate-100 bg-white px-6 py-4 shadow-[0_-12px_24px_rgba(15,23,42,0.06)]"> + <button @onclick="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">@AppLocalization.Text("common.cancel", "Cancel")</button> + <button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors"> + @(isSaving ? AppLocalization.Text("common.loadingSpinner", "Loading...") : AppLocalization.Text("common.update", "Update")) + </button> + </div> + </div> + </div> +} + +<ConfirmDialog IsVisible="showDeleteConfirm" + Title="@AppLocalization.Text("issue.deleteIssue", "Delete Issue?")" + Message="@deleteConfirmMessage" + ConfirmText="@AppLocalization.Text("issue.yesDelete", "Yes, Delete")" + Icon="trash-2" + IconBgClass="bg-rose-50" + IconTextClass="text-rose-600" + ButtonClass="bg-rose-600 hover:bg-rose-700" + OnConfirm="ConfirmDelete" + OnCancel="CloseDeleteConfirm" /> + +<ConfirmDialog IsVisible="showSaveConfirm" + Title="@AppLocalization.Text("common.saveChanges", "Save Changes?")" + Message="@AppLocalization.Text("issue.saveConfirmMessage", "Are you sure you want to save these issue changes?")" + ConfirmText="@AppLocalization.Text("issue.yesSave", "Yes, Save")" + Icon="save" + IconBgClass="bg-violet-50" + IconTextClass="text-violet-600" + ButtonClass="bg-violet-600 hover:bg-violet-700" + OnConfirm="ConfirmSave" + OnCancel="CloseSaveConfirm" /> + +@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; + + [Parameter, SupplyParameterFromQuery(Name = "created")] + public bool Created { get; set; } + + private string? successMessage; + private string? errorMessage; + private string deleteConfirmMessage = AppLocalization.Text("issue.deleteConfirmMessage", "Are you sure you want to delete this issue?"); + private long deleteIssueId; + private long currentUserId; + + private List<IssueDto> issues = new(); + private List<TaskDto> tasks = new(); + private List<ProjectDto> projects = new(); + private List<UserDto> 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? formTitleMy; + private string? formDescription; + private string? formDescriptionMy; + 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 string? formDelayReasonMy; + private bool formIsBlocked; + private string? formBlockedBy; + private string? formBlockedByMy; + private int formEscalationLevel; + private bool burmeseGenerated; + private bool englishGenerated; + private bool burmeseNeedsReview; + private bool englishNeedsReview; + + private string SelectedTaskLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task ? Content.GetTaskTitle(task) : $"{AppLocalization.Text("common.task", "Task")} #{AppLocalization.Number(formTaskId)}"; + private string SelectedProjectLabel => tasks.FirstOrDefault(t => t.Id == formTaskId) is { } task + ? projects.FirstOrDefault(p => p.Id == task.ProjectId) is { } project ? Content.GetProjectName(project) : $"{AppLocalization.Text("common.project", "Project")} #{AppLocalization.Number(task.ProjectId)}" + : AppLocalization.Text("issue.unknownProject", "Unknown project"); + + private IEnumerable<UserDto> filteredProjectMembers => projectMembers.Where(u => u.IsActive); + private static string GetTaskCode(long taskId) => $"TSK-{AppLocalization.LocalizeDigits(taskId.ToString("D4", System.Globalization.CultureInfo.InvariantCulture))}"; + + 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"); + + if (Created) + { + successMessage = AppLocalization.Text("issue.created", "Issue created successfully!"); + } + + var client = ApiClient.CreateClient(); + try + { + var issuesTask = LoadIssuesPageAsync(client); + var tasksTask = client.GetFromJsonAsync<List<TaskDto>>("Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive); + + await System.Threading.Tasks.Task.WhenAll(issuesTask, tasksTask, projectsTask, usersTask); + + 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(); + ApplyPageResult(issuesTask.Result); + } + catch (Exception ex) + { + errorMessage = AppLocalization.Text("issue.loadFailed", "Unable to load issues. Please try again."); + Console.WriteLine($"Load issues error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private async Task<PagedResult<IssueDto>?> LoadIssuesPageAsync(HttpClient? client = null) + { + client ??= ApiClient.CreateClient(); + return await client.GetFromJsonAsync<PagedResult<IssueDto>>(BuildIssuesUrl(), Serialization.CaseInsensitive); + } + + private void ApplyPageResult(PagedResult<IssueDto>? result) + { + issues = result?.Items ?? new(); + totalCount = result?.TotalCount ?? 0; + totalPages = result?.TotalPages ?? 0; + currentPage = result?.Page ?? currentPage; + pageSize = result?.PageSize ?? pageSize; + RemoveOrphanedIssues(); + } + + private bool HasActiveParentRecords(IssueDto issue) + { + var task = tasks.FirstOrDefault(t => t.Id == issue.TaskId); + return task != null && + task.ProjectId == issue.ProjectId && + projects.Any(project => project.Id == task.ProjectId); + } + + private void RemoveOrphanedIssues() + { + var activeIssues = issues.Where(HasActiveParentRecords).ToList(); + var removedCount = issues.Count - activeIssues.Count; + if (removedCount == 0) + { + return; + } + + issues = activeIssues; + totalCount = Math.Max(0, totalCount - removedCount); + totalPages = pageSize > 0 ? (int)Math.Ceiling(totalCount / (double)pageSize) : 0; + } + + private async Task LoadIssues() + { + var client = ApiClient.CreateClient(); + var result = await LoadIssuesPageAsync(client); + ApplyPageResult(result); + } + + private string BuildIssuesUrl() + { + var query = new List<string> + { + $"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; + } + + // The page can still contain a DTO loaded before its task/project was deleted. + // Never open an issue editor for an orphaned issue. + if (!HasActiveParentRecords(issue)) + { + issues.RemoveAll(current => current.Id == issue.Id); + totalCount = Math.Max(0, totalCount - 1); + totalPages = pageSize > 0 ? (int)Math.Ceiling(totalCount / (double)pageSize) : 0; + return; + } + + editingId = issue.Id; + formTaskId = issue.TaskId; + formTitle = issue.Title; + formTitleMy = issue.TitleMy; + formDescription = issue.Description; + formDescriptionMy = issue.DescriptionMy; + formAssignedTo = issue.AssignedTo; + formActualHours = issue.ActualHours; + formStartDate = issue.StartDate; + formDueDate = issue.DueDate; + formStatusId = issue.StatusId; + formPriorityId = issue.PriorityId; + formDelayReason = issue.DelayReason; + formDelayReasonMy = issue.DelayReasonMy; + formIsBlocked = issue.IsBlocked; + formBlockedBy = issue.BlockedBy; + formBlockedByMy = issue.BlockedByMy; + formEscalationLevel = issue.EscalationLevel; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + errorMessage = null; + showModal = true; + + await LoadMembersForTaskAsync(issue.TaskId); + } + + private void CloseModal() + { + showModal = false; + errorMessage = null; + editingId = 0; + } + + private void MoveIssueTitleToEnglish() => formTitle = formTitleMy ?? string.Empty; + private void MoveIssueTitleToBurmese() => formTitleMy = formTitle; + private void MoveIssueDescriptionToEnglish() => formDescription = formDescriptionMy; + private void MoveIssueDescriptionToBurmese() => formDescriptionMy = formDescription; + private void MoveIssueDelayToEnglish() => formDelayReason = formDelayReasonMy; + private void MoveIssueDelayToBurmese() => formDelayReasonMy = formDelayReason; + private void MoveIssueBlockedByToEnglish() => formBlockedBy = formBlockedByMy; + private void MoveIssueBlockedByToBurmese() => formBlockedByMy = formBlockedBy; + + private void MarkGenerated(bool english) + { + if (english) { englishGenerated = true; englishNeedsReview = true; } + else { burmeseGenerated = true; burmeseNeedsReview = true; } + } + + private Task GenerateIssueEnglishTitle(TranslationResult result) { formTitle = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseTitle(TranslationResult result) { formTitleMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateIssueEnglishDescription(TranslationResult result) { formDescription = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseDescription(TranslationResult result) { formDescriptionMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateIssueEnglishDelay(TranslationResult result) { formDelayReason = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseDelay(TranslationResult result) { formDelayReasonMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateIssueEnglishBlockedBy(TranslationResult result) { formBlockedBy = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateIssueBurmeseBlockedBy(TranslationResult result) { formBlockedByMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + + 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<Result<IEnumerable<UserDto>>>($"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(), + TitleMy = formTitleMy, + Description = formDescription, + DescriptionMy = formDescriptionMy, + AssignedTo = formAssignedTo, + ActualHours = formActualHours, + DelayReason = formDelayReason, + DelayReasonMy = formDelayReasonMy, + IsBlocked = formIsBlocked, + BlockedBy = formBlockedBy, + BlockedByMy = formBlockedByMy, + EscalationLevel = formEscalationLevel, + StartDate = formStartDate, + DueDate = formDueDate, + StatusId = formStatusId, + PriorityId = formPriorityId + }; + + var response = await client.PutAsJsonAsync($"Issue/{editingId}", dto); + if (response.IsSuccessStatusCode) + { + successMessage = AppLocalization.Text("issue.updated", "Issue updated successfully!"); + showModal = false; + await LoadIssues(); + } + else + { + errorMessage = AppLocalization.Text("issue.updateFailed", "Unable to update the issue. Please try again."); + } + } + catch (Exception ex) + { + errorMessage = AppLocalization.Text("issue.updateFailed", "Unable to update the issue. Please try again."); + Console.WriteLine($"Update issue error: {ex.Message}"); + } + finally + { + isSaving = false; + } + } + + private void RequestDeleteConfirmation(long issueId) + { + if (!canDeleteIssue) + { + return; + } + + deleteIssueId = issueId; + deleteConfirmMessage = AppLocalization.Text("issue.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 = AppLocalization.Text("issue.deleted", "Issue deleted successfully!"); + await LoadIssues(); + } + else + { + errorMessage = AppLocalization.Text("issue.deleteFailed", "Unable to delete the issue. Please try again."); + } + } + catch (Exception ex) + { + errorMessage = AppLocalization.Text("issue.deleteFailed", "Unable to delete the issue. Please try again."); + Console.WriteLine($"Delete issue error: {ex.Message}"); + } + } + + private static async Task<string> 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 => AppLocalization.Text("status.todo", "To Do"), + AppTaskStatus.InProgress => AppLocalization.Text("status.inprogress", "In Progress"), + AppTaskStatus.Done => AppLocalization.Text("status.done", "Done"), + _ => AppLocalization.Text("common.unknown", "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 => AppLocalization.Text("priority.low", "Low"), + TaskPriority.Medium => AppLocalization.Text("priority.medium", "Medium"), + TaskPriority.High => AppLocalization.Text("priority.high", "High"), + _ => AppLocalization.Text("common.unknown", "Unknown") + }; + + private static string GetEscalationLabel(int level) => AppLocalization.EscalationLabel(level); +} 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..f5c725f928a9982c2350b1bd0c4832360b40f926 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/ProjectAssign.razor @@ -0,0 +1,492 @@ +@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 AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +<PageTitle>@AppLocalization.PageTitle("projectAssignment", "Project Assignment")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex items-center justify-between"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("projectAssignment", "Project Assignment")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("project.assignDescription", "Assign team members to projects to control their task visibility.")</p> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingProjectsAndUsers", "Loading projects and users...")" /> + } + else + { + <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> + <!-- Left panel: Projects list --> + <div class="lg:col-span-1 rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden flex flex-col h-[600px]"> + <div class="p-4 border-b border-slate-100 bg-slate-50/60"> + <h3 class="font-bold text-slate-800 text-sm mb-3">@AppLocalization.Text("common.selectProject", "Select Project")</h3> + <div class="relative flex gap-2"> + <div class="relative flex-1"> + <input @bind="projectSearchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchProjects", "Search projects...")" + class="w-full rounded-lg border border-slate-200 pl-9 pr-3 py-1.5 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <i data-lucide="search" class="absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400"></i> + </div> + <button @onclick="ApplyProjectSearch" class="inline-flex items-center rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shrink-0"> + <i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetProjectSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-1.5 text-slate-700 hover:bg-slate-50 transition-colors shrink-0"> + <i data-lucide="rotate-ccw" class="h-3.5 w-3.5 text-slate-500"></i> + </button> + </div> + </div> + <div class="flex-1 overflow-y-auto divide-y divide-slate-100"> + @if (FilteredProjects.Any()) + { + @foreach (var proj in FilteredProjects) + { + var isSelected = SelectedProject?.Id == proj.Id; + <button @onclick="() => SelectProject(proj)" + class="w-full text-left p-4 hover:bg-slate-50/60 transition-colors flex flex-col gap-1 @(isSelected ? "bg-violet-50/60 border-l-4 border-violet-600 pl-3" : "")"> + <div class="flex items-center justify-between"> + <span class="text-sm font-semibold text-slate-900 @(isSelected ? "text-violet-700" : "")">@Content.GetProjectName(proj)</span> + <span class="text-[10px] text-slate-400">@DisplayFormats.Date(proj.StartDate) - @DisplayFormats.Date(proj.EndDate)</span> + </div> + @if (!string.IsNullOrEmpty(Content.GetText(proj.Description, proj.DescriptionMy))) + { + <p class="text-xs text-slate-500 line-clamp-1">@Content.GetText(proj.Description, proj.DescriptionMy)</p> + } + </button> + } + } + else + { + <div class="p-8 text-center text-slate-400 text-xs">@AppLocalization.Text("common.noProjectsFound", "No projects found.")</div> + } + </div> + </div> + + <!-- Right panel: Assignment controls --> + <div class="lg:col-span-2 rounded-xl border border-slate-200 bg-white shadow-sm flex flex-col h-[600px] overflow-hidden"> + @if (SelectedProject == null) + { + <div class="flex-1 flex flex-col items-center justify-center p-8 text-center bg-slate-50/30"> + <div class="h-12 w-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3"> + <i data-lucide="folder-kanban" class="h-6 w-6"></i> + </div> + <h4 class="text-sm font-bold text-slate-800">@AppLocalization.Text("common.noProjectSelected", "No Project Selected")</h4> + <p class="text-xs text-slate-500 mt-1 max-w-sm">@AppLocalization.Text("project.selectProjectPrompt", "Please select a project from the left panel to manage user assignments.")</p> + </div> + } + else + { + <!-- Selected Project Header --> + <div class="p-6 border-b border-slate-100 bg-slate-50 flex flex-col md:flex-row md:items-center justify-between gap-4"> + <div> + <span class="text-[10px] font-bold text-violet-600 uppercase tracking-wider">@AppLocalization.Text("common.currentlyManaging", "Currently Managing")</span> + <h3 class="text-xl font-bold text-slate-900">@Content.GetProjectName(SelectedProject)</h3> + @if (!string.IsNullOrEmpty(Content.GetText(SelectedProject.Description, SelectedProject.DescriptionMy))) + { + <p class="text-xs text-slate-500 mt-1">@Content.GetText(SelectedProject.Description, SelectedProject.DescriptionMy)</p> + } + </div> + <div class="flex items-center gap-2 shrink-0"> + <span class="text-xs text-slate-500 bg-slate-100 px-3 py-1 rounded-full border border-slate-200"> + @DisplayFormats.Date(SelectedProject.StartDate) - @DisplayFormats.Date(SelectedProject.EndDate) + </span> + </div> + </div> + + <div class="px-6 py-3 border-b border-slate-100 bg-white"> + <div class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-3"> + <span class="text-[10px] font-bold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("project.assignedMembers", "Assigned Members")</span> + <span class="text-xs font-bold text-violet-700">@AppLocalization.Number(SelectedUserIds.Count) @AppLocalization.Text("common.selected", "selected")</span> + </div> + @{ + var selectedUsers = SelectedUsers.ToList(); + } + @if (selectedUsers.Any()) + { + <div class="flex flex-wrap gap-2 max-h-20 overflow-y-auto pr-1"> + @foreach (var user in selectedUsers) + { + <span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-slate-700"> + <span class="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-[10px] font-bold"> + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + </span> + <span>@Content.GetUserFullName(user)</span> + </span> + } + </div> + } + else + { + <div class="text-xs text-slate-400">@AppLocalization.Text("project.noAssignedMembers", "No members selected for this project yet.")</div> + } + </div> + </div> + + <!-- Filter & Selection Stats --> + <div class="px-6 py-3 border-b border-slate-100 bg-slate-50/30 flex flex-col sm:flex-row sm:items-center justify-between gap-3"> + <div class="relative max-w-xs flex-1 flex gap-2"> + <div class="relative flex-1"> + <input @bind="userSearchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchTeamMembers", "Search team members...")" + class="w-full rounded-lg border border-slate-200 pl-9 pr-3 py-1.5 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <i data-lucide="search" class="absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400"></i> + </div> + <button @onclick="ApplyUserSearch" class="inline-flex items-center rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shrink-0"> + <i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetUserSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-1.5 text-slate-700 hover:bg-slate-50 transition-colors shrink-0"> + <i data-lucide="rotate-ccw" class="h-3.5 w-3.5 text-slate-500"></i> + </button> + </div> + @if (isAdminUser) + { + <div class="w-40 shrink-0"> + <select @bind="selectedRoleId" class="w-full rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allRoles", "All Roles")</option> + @foreach (var r in roles) + { + <option value="@r.Id">@Content.GetRoleName(r)</option> + } + </select> + </div> + } + else + { + <div class="text-xs font-semibold text-violet-700 bg-violet-50 border border-violet-100 px-3 py-1.5 rounded-lg shrink-0"> + @AppLocalization.Text("project.employeesOnly", "Employees only") + </div> + } + <div class="flex items-center gap-3 shrink-0"> + <button @onclick="SelectAllUsers" class="text-xs font-semibold text-violet-600 hover:text-violet-800 transition-colors">@AppLocalization.Text("common.selectAll", "Select All")</button> + <span class="text-slate-300">|</span> + <button @onclick="DeselectAllUsers" class="text-xs font-semibold text-slate-600 hover:text-slate-800 transition-colors">@AppLocalization.Text("common.deselectAll", "Deselect All")</button> + <span class="text-slate-300">|</span> + <span class="text-xs font-bold text-slate-600">@AppLocalization.Number(SelectedUserIds.Count) @AppLocalization.Text("common.selected", "selected")</span> + </div> + </div> + + <!-- Users list checklist --> + <div class="flex-1 overflow-y-auto p-6 space-y-2"> + @if (isLoadingMembers) + { + <div class="flex justify-center py-12"> + <LoadingSpinner Message="@AppLocalization.Text("common.loadingMembers", "Loading members...")" /> + </div> + } + else + { + @if (FilteredUsers.Any()) + { + <div class="grid grid-cols-1 md:grid-cols-2 gap-3"> + @foreach (var user in FilteredUsers) + { + var isChecked = SelectedUserIds.Contains(user.Id); + <label class="flex items-center gap-3 p-3 rounded-xl border border-slate-100 bg-white hover:bg-slate-50/50 hover:border-slate-200 cursor-pointer transition-all shadow-sm select-none @(isChecked ? "border-violet-100 bg-violet-50/10" : "")"> + <input type="checkbox" checked="@isChecked" + @onchange="(e) => ToggleUserSelection(user.Id, (bool)(e.Value ?? false))" + class="h-4 w-4 rounded border-slate-300 accent-violet-600 focus:ring-violet-500" /> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0"> + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + </span> + <div class="min-w-0 flex-1"> + <p class="text-sm font-semibold text-slate-800 truncate">@Content.GetUserFullName(user)</p> + <p class="text-xs text-slate-400 truncate">@@@user.Username</p> + </div> + </label> + } + </div> + } + else + { + <div class="text-center text-slate-400 py-12 text-sm">@AppLocalization.Text("common.noTeamMembersFound", "No team members match your search criteria.")</div> + } + } + </div> + + <!-- Action bar --> + <div class="p-6 border-t border-slate-100 bg-slate-50/50 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <div> + @if (!string.IsNullOrEmpty(statusMessage)) + { + <p class="text-sm font-medium @(isError ? "text-red-600" : "text-emerald-700")"> + <i data-lucide="@(isError ? "alert-circle" : "check-circle")" class="h-4 w-4 inline mr-1.5"></i> + @statusMessage + </p> + } + </div> + @if (canSaveAssignments) + { + <button @onclick="RequestSaveConfirmation" disabled="@(isSaving || isLoadingMembers)" + class="w-full sm:w-auto inline-flex items-center justify-center rounded-lg bg-violet-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm"> + <i data-lucide="user-check" class="h-4 w-4 mr-2"></i> + @(isSaving ? AppLocalization.Text("common.loadingSpinner", "Loading...") : AppLocalization.Text("common.saveAssignmentChanges", "Save Assignment Changes")) + </button> + } + </div> + } + </div> + </div> + } +</div> + +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@AppLocalization.Text("project.saveAssignmentTitle", "Save Assignment?")" + Message="@AppLocalization.Text("project.saveAssignmentConfirm", "Are you sure you want to save these project member assignments?")" + ConfirmText="@AppLocalization.Text("issue.yesSave", "Yes, Save")" + Icon="user-check" + OnConfirm="ConfirmSaveAssignments" + OnCancel="CloseConfirmDialog" /> + +@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<ProjectDto> projects = new(); + private List<UserDto> allUsers = new(); + private List<RoleDto> roles = new(); + private long? adminRoleId; + private long? employeeRoleId; + private bool isAdminUser; + + private ProjectDto? SelectedProject { get; set; } + private HashSet<long> 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<List<ProjectDto>>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive); + var rolesTask = client.GetFromJsonAsync<List<RoleDto>>("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<ProjectDto> FilteredProjects => + string.IsNullOrWhiteSpace(projectSearch) + ? projects + : projects.Where(p => + p.Name.Contains(projectSearch, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(p.NameMy) && p.NameMy.Contains(projectSearch, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(p.Description) && p.Description.Contains(projectSearch, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(p.DescriptionMy) && p.DescriptionMy.Contains(projectSearch, StringComparison.OrdinalIgnoreCase))); + + private IEnumerable<UserDto> 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} {u.FirstNameMy} {u.LastNameMy}".Contains(userSearch, StringComparison.OrdinalIgnoreCase) + || u.Username.Contains(userSearch, StringComparison.OrdinalIgnoreCase)); + } + return result; + } + } + + private IEnumerable<UserDto> SelectedUsers => + allUsers + .Where(u => SelectedUserIds.Contains(u.Id)) + .OrderBy(u => u.FirstName) + .ThenBy(u => u.LastName); + + 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<Result<IEnumerable<UserDto>>>($"Project/{project.Id}/members", Serialization.CaseInsensitive); + if (membersResult?.IsSuccess == true && membersResult.Value != null) + { + SelectedUserIds = new HashSet<long>(membersResult.Value.Select(u => u.Id)); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading project members: {ex.Message}"); + statusMessage = AppLocalization.Text("project.failedLoadMembers", "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 = AppLocalization.Text("project.assignmentsSaved", "Project assignments saved successfully!"); + successMessage = AppLocalization.Text("common.assignmentSaved", "Assignment saved successfully!"); + isError = false; + } + else + { + statusMessage = AppLocalization.Text("project.failedSaveAssignment", "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..29bd650e135a2fac86ca3a5a02789e0cea28dffa --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Projects/Projects.razor @@ -0,0 +1,599 @@ +@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 + + +<PageTitle>@AppLocalization.PageTitle("projects", "Projects")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex items-center justify-between"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("projects", "Projects")</h2> + <p class="text-slate-500 mt-1">@(isAdmin ? AppLocalization.Text("project.manageAll", "Manage all projects.") : AppLocalization.Text("project.yourAssigned", "Your assigned projects."))</p> + </div> + @if (canCreateProject) + { + <button @onclick="OpenCreateModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="plus" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("project.newProject", "New Project") + </button> + } + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingProjects", "Loading projects...")" /> + } + else + { + <!-- Search Panel --> + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm mb-4"> + <div class="flex items-center gap-3"> + <div class="relative flex-1 max-w-md"> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchProjects", "Search projects...")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2.5 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + </div> + </div> + + <!-- Projects Table --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.name", "Name")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.startDate", "Start Date")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.endDate", "End Date")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.created", "Created")</th> + <th class="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actions", "Actions")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (PagedProjects.Any()) + { + @foreach (var entry in PagedProjects.Select((project, index) => (project, index))) + { + var project = entry.project; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4"> + <button @onclick="() => NavigateToKanban(project.Id)" class="text-sm font-semibold text-slate-900 hover:text-violet-600 transition-colors"> + @Content.GetProjectName(project) + </button> + @if (!string.IsNullOrEmpty(Content.GetProjectDescription(project))) + { + <p class="text-xs text-slate-400 mt-0.5 truncate max-w-xs">@Content.GetProjectDescription(project)</p> + } + </td> + <td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(project.StartDate)</td> + <td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(project.EndDate)</td> + <td class="px-6 py-4 text-sm text-slate-400">@DisplayFormats.Date(project.CreatedAt)</td> + <td class="px-6 py-4 text-right"> + <div class="flex items-center justify-end space-x-2"> + @if (canEditProject || canDeleteProject) + { + @if (canEditProject) + { + <button @onclick="() => OpenEditModal(project)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="@AppLocalization.Text("action.edit", "Edit")"> + <i data-lucide="pencil" class="h-4 w-4"></i> + </button> + } + @if (canDeleteProject) + { + <button @onclick="() => RequestDeleteConfirmation(project.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="@AppLocalization.Text("action.delete", "Delete")"> + <i data-lucide="trash-2" class="h-4 w-4"></i> + </button> + } + } + </div> + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="6" Icon="folder-kanban" Title="@AppLocalization.Text("project.noProjectsYet", "No projects yet")" Subtitle="@(canCreateProject ? AppLocalization.Text("project.createFirstProject", "Create your first project to get started.") : AppLocalization.Text("project.noAssignedProjects", "You have not been assigned to any projects."))" /> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="TotalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } +</div> + +@* ===== Create / Edit Modal ===== *@ +@if (showModal) +{ + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> + <div class="bg-white rounded-2xl shadow-lg w-full max-w-lg mx-4 overflow-hidden"> + <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100"> + <h3 class="text-lg font-semibold text-slate-900">@(isEditing ? AppLocalization.Text("project.editProject", "Edit Project") : AppLocalization.Text("project.newProject", "New Project"))</h3> + <button @onclick="CloseModal" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"> + <i data-lucide="x" class="h-5 w-5"></i> + </button> + </div> + <div class="p-6 space-y-4"> + <BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formNameMy))" + EnglishComplete="@(!string.IsNullOrWhiteSpace(formName))" + BurmeseAiGenerated="@burmeseGenerated" + EnglishAiGenerated="@englishGenerated" + BurmeseNeedsReview="@burmeseNeedsReview" + EnglishNeedsReview="@englishNeedsReview"> + <BurmeseContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">အမည် <span class="text-red-500">*</span></label> + <input @bind="formNameMy" type="text" placeholder="အမည်" class="w-full rounded-lg border @(isNameError && string.IsNullOrWhiteSpace(formName) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formNameMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveProjectNameToEnglish" /> + <BilingualTranslationAction SourceText="@formNameMy" SourceLanguage="my" TargetLanguage="en" IsName="false" TargetHasText="@(!string.IsNullOrWhiteSpace(formName))" Generated="GenerateProjectEnglishName" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">ဖော်ပြချက်</label> + <textarea @bind="formDescriptionMy" rows="3" placeholder="ဖော်ပြချက်" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescriptionMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveProjectDescriptionToEnglish" /> + <BilingualTranslationAction SourceText="@formDescriptionMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescription))" Generated="GenerateProjectEnglishDescription" /> + </BurmeseContent> + <EnglishContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.name", "Name") <span class="text-red-500">*</span></label> + <input @bind="formName" type="text" placeholder="@AppLocalization.Text("project.projectName", "Project name")" class="w-full rounded-lg border @(isNameError && string.IsNullOrWhiteSpace(formNameMy) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formName" ExpectedBurmese="false" MoveToOtherLanguage="MoveProjectNameToBurmese" /> + <BilingualTranslationAction SourceText="@formName" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formNameMy))" Generated="GenerateProjectBurmeseName" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label> + <textarea @bind="formDescription" rows="3" placeholder="@AppLocalization.Text("project.projectDescriptionOptional", "Project description (optional)")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescription" ExpectedBurmese="false" MoveToOtherLanguage="MoveProjectDescriptionToBurmese" /> + <BilingualTranslationAction SourceText="@formDescription" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescriptionMy))" Generated="GenerateProjectBurmeseDescription" /> + </EnglishContent> + </BilingualEditorTabs> + <div class="grid grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date") <span class="text-red-500">*</span></label> + <div class="relative"> + <input @bind="formStartDate" type="date" class="w-full rounded-lg border @(isDateError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:border-transparent transition-all pr-10" /> + @if (isDateError) + { + <svg class="absolute right-3 top-2.5 h-4 w-4 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> + } + </div> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.endDate", "End Date") <span class="text-red-500">*</span></label> + <div class="relative"> + <input @bind="formEndDate" type="date" class="w-full rounded-lg border @(isDateError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:border-transparent transition-all pr-10" /> + @if (isDateError) + { + <svg class="absolute right-3 top-2.5 h-4 w-4 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> + } + </div> + </div> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5 font-sans">@AppLocalization.Text("common.budgetedHours", "Budgeted Hours")</label> + <input @bind="formBudgetedHours" type="number" min="0" placeholder="@AppLocalization.Text("common.budgetedHoursOptional", "Budgeted hours (optional)")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all font-sans" /> + </div> + @if (isDateError) + { + <p class="mt-1 text-xs text-red-500">@dateErrorMessage</p> + } + @if (!string.IsNullOrEmpty(errorMessage)) + { + <div class="p-3 rounded-lg bg-red-50 border border-red-100"> + <p class="text-sm text-red-600">@errorMessage</p> + </div> + } + </div> + <div class="flex items-center justify-end space-x-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50"> + <button @onclick="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">@AppLocalization.Text("common.cancel", "Cancel")</button> + <button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors"> + @(isSaving ? AppLocalization.Text("common.saving", "Saving...") : (isEditing ? AppLocalization.Text("action.update", "Update") : AppLocalization.Text("action.create", "Create"))) + </button> + </div> + </div> + </div> +} + +@* ===== Confirm Dialog ===== *@ +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@confirmTitle" + Message="@confirmMessage" + ConfirmText="@confirmButtonText" + Icon="@confirmIcon" + IconBgClass="@confirmIconBg" + IconTextClass="@confirmIconText" + ButtonClass="@confirmButtonClass" + OnConfirm="HandleConfirmAction" + OnCancel="CloseConfirmDialog" /> + +@code { + private bool isLoading = true; + private bool isAdmin = false; + private bool canCreateProject = false; + private bool canEditProject = false; + private bool canDeleteProject = 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 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<Task>? confirmAction; + private List<ProjectDto> projects = 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<ProjectDto> PagedProjects => projects; + + // Form fields + private long editingId; + private string formName = ""; + private string? formNameMy; + private string? formDescription; + private string? formDescriptionMy; + private bool burmeseGenerated; + private bool englishGenerated; + private bool burmeseNeedsReview; + private bool englishNeedsReview; + 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 = ""; + + private void MoveProjectNameToEnglish() { formName = formNameMy ?? string.Empty; } + private void MoveProjectNameToBurmese() { formNameMy = formName; } + private void MoveProjectDescriptionToEnglish() { formDescription = formDescriptionMy; } + private void MoveProjectDescriptionToBurmese() { formDescriptionMy = formDescription; } + private void MarkGenerated(bool english) + { + if (english) { englishGenerated = true; englishNeedsReview = true; } + else { burmeseGenerated = true; burmeseNeedsReview = true; } + } + private Task GenerateProjectEnglishName(TranslationResult result) { formName = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateProjectBurmeseName(TranslationResult result) { formNameMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateProjectEnglishDescription(TranslationResult result) { formDescription = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateProjectBurmeseDescription(TranslationResult result) { formDescriptionMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + + 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<PagedResult<ProjectDto>>(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<string> + { + $"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"); + } + + private void ClearValidation() + { + isNameError = false; + nameErrorMessage = ""; + isDateError = false; + dateErrorMessage = ""; + errorMessage = null; + } + + private void OpenCreateModal() + { + isEditing = false; + editingId = 0; + formName = ""; + formNameMy = null; + formDescription = null; + formDescriptionMy = null; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + 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; + formNameMy = project.NameMy; + formDescription = project.Description; + formDescriptionMy = project.DescriptionMy; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + formStartDate = project.StartDate; + formEndDate = project.EndDate; + formBudgetedHours = project.BudgetedHours; + ClearValidation(); + showModal = true; + } + + private void CloseModal() + { + showModal = false; + ClearValidation(); + } + + // ─── Assignment Modal ───────────────────────────────────────────────────── + + private void RequestSaveConfirmation() + { + ClearValidation(); + bool hasError = false; + + if (string.IsNullOrWhiteSpace(formName) && string.IsNullOrWhiteSpace(formNameMy)) + { + isNameError = true; + nameErrorMessage = AppLocalization.Text("validation.projectNameRequired", "Project name is required."); + hasError = true; + } + if (formEndDate < formStartDate) + { + isDateError = true; + dateErrorMessage = AppLocalization.Text("validation.endDateBeforeStart", "End date cannot be before start date."); + hasError = true; + } + + if (hasError) return; + + if (isEditing) + { + confirmTitle = AppLocalization.Text("project.updateTitle", "Update Project?"); + confirmMessage = AppLocalization.Text("project.updateConfirm", "Are you sure you want to update this project?"); + 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("project.createTitle", "Create Project?"); + confirmMessage = AppLocalization.Text("project.createConfirm", "Are you sure you want to create this project?"); + 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 = SaveProject; + showConfirmDialog = true; + } + + private void RequestDeleteConfirmation(long id) + { + confirmTitle = AppLocalization.Text("project.deleteTitle", "Delete Project?"); + confirmMessage = AppLocalization.Text("project.deleteConfirm", "Are you sure you want to delete this project? 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 = () => 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) && string.IsNullOrWhiteSpace(formNameMy)) || formEndDate < formStartDate) + { + return; + } + + isSaving = true; + errorMessage = null; + var client = ApiClient.CreateClient(); + + try + { + if (isEditing) + { + var dto = new UpdateProjectDto + { + Name = formName, + NameMy = formNameMy, + Description = formDescription, + DescriptionMy = formDescriptionMy, + StartDate = formStartDate, + EndDate = formEndDate, + BudgetedHours = formBudgetedHours + }; + var response = await client.PutAsJsonAsync($"Project/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToUpdateProject; + return; + } + } + else + { + var dto = new CreateProjectDto + { + Name = formName, + NameMy = formNameMy, + Description = formDescription, + DescriptionMy = formDescriptionMy, + StartDate = formStartDate, + EndDate = formEndDate, + CreatedById = currentUserId, + BudgetedHours = formBudgetedHours + }; + var response = await client.PostAsJsonAsync("Project", dto); + var res = await response.Content.ReadFromJsonAsync<Result<ProjectDto>>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToCreateProject; + return; + } + + // After creating, reload + await LoadProjects(); + CloseModal(); + successMessage = AppLocalization.Text("project.created", "Project created successfully!"); + return; + } + + CloseModal(); + await LoadProjects(); + successMessage = isEditing ? AppLocalization.Text("project.updated", "Project updated successfully!") : AppLocalization.Text("project.created", "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<Result>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + await JS.InvokeVoidAsync("alert", res?.ErrorMessage ?? AppLocalization.Text("project.failedDelete", "Failed to delete project.")); + return; + } + await LoadProjects(); + if (currentPage > TotalPages && TotalPages > 0) + currentPage = TotalPages; + successMessage = AppLocalization.Text("project.deleted", "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..54a898a7d77e87e698ea7e94cb25415cc90d505d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/AgingBucketChart.razor @@ -0,0 +1,32 @@ +<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> + @if (!Buckets.Any()) + { + <div class="col-span-full rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div> + } + else + { + @foreach (var bucket in Buckets) + { + var height = MaxValue > 0 ? Math.Clamp(bucket.Value / MaxValue * 100, 8, 100) : 8; + <div class="rounded-xl border border-slate-100 bg-slate-50 p-3"> + <div class="flex h-28 items-end rounded-lg bg-white p-2"> + <div class="w-full rounded-md" style="height: @height%; background-color: @bucket.Color;"></div> + </div> + <div class="mt-3 flex items-center justify-between gap-2"> + <span class="text-xs font-semibold text-slate-700">@bucket.Label</span> + <span class="text-lg font-bold tabular-nums text-slate-950">@AppLocalization.Number(bucket.Value, "0")</span> + </div> + @if (!string.IsNullOrWhiteSpace(bucket.Detail)) + { + <p class="mt-1 text-[11px] text-slate-500">@bucket.Detail</p> + } + </div> + } + } +</div> + +@code { + [Parameter] public IReadOnlyList<ReportChartItem> Buckets { get; set; } = Array.Empty<ReportChartItem>(); + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noAgingData", "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..27e7298d162f9def7ed36b7f3917671e98c454b4 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/DonutStatusChart.razor @@ -0,0 +1,93 @@ +<div class="grid gap-4 sm:grid-cols-[180px_minmax(0,1fr)] sm:items-center"> + @if (Total <= 0) + { + <div class="col-span-full rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <div class="relative mx-auto h-40 w-40"> + <svg viewBox="0 0 180 180" class="h-40 w-40" role="img" aria-label="@AriaLabel"> + @foreach (var slice in Slices) + { + if (slice.IsFullCircle) + { + <circle cx="90" cy="90" r="66" fill="@slice.Color" stroke="var(--surface)" stroke-width="2" /> + } + else + { + <path d="@slice.Path" fill="@slice.Color" stroke="var(--surface)" stroke-width="2" stroke-linejoin="round" /> + } + + } + </svg> + </div> + <div class="space-y-2"> + @foreach (var item in Items) + { + var pct = Total > 0 ? item.Value / Total * 100 : 0; + <div class="flex items-center justify-between gap-3 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2"> + <div class="flex min-w-0 items-center gap-2"> + <span class="h-2.5 w-2.5 shrink-0 rounded-full" style="background-color: @item.Color"></span> + <span class="truncate text-xs font-medium text-slate-700">@item.Label</span> + </div> + <span class="shrink-0 text-xs font-semibold tabular-nums text-slate-900">@AppLocalization.Number(item.Value, "0") · @AppLocalization.Number(pct, "0")%</span> + </div> + } + </div> + } +</div> + +@code { + [Parameter] public IReadOnlyList<ReportChartItem> Items { get; set; } = Array.Empty<ReportChartItem>(); + [Parameter] public string CenterLabel { get; set; } = AppLocalization.Text("report.total", "total"); + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noChartData", "No data available."); + [Parameter] public string AriaLabel { get; set; } = AppLocalization.Text("report.statusDistribution", "Status distribution"); + + private double Total => Items.Sum(item => Math.Max(0, item.Value)); + private IReadOnlyList<Slice> Slices => BuildSlices(); + + private IReadOnlyList<Slice> BuildSlices() + { + const double center = 90; + const double radius = 66; + var startAngle = -90.0; + var slices = new List<Slice>(); + + 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..36792ecb55a559b70809fd6f41626b3be65099cf --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/FunnelChart.razor @@ -0,0 +1,69 @@ +<div class="space-y-3"> + @if (!Items.Any()) + { + <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div> + } + 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); + <div class="rounded-lg border border-transparent p-2 transition-colors hover:border-slate-100 hover:bg-slate-50" title="@BuildTitle(stage, percent)"> + <div class="mb-1.5 grid grid-cols-[96px_1fr_72px] items-center gap-3 text-xs"> + <span class="truncate font-semibold text-slate-700">@stage.Label</span> + <span class="text-[11px] text-slate-400">@GetStageHelper(stage)</span> + <span class="text-right font-bold tabular-nums text-slate-950">@AppLocalization.Number(stage.Value, "0") · @AppLocalization.Number(percent, "0")%</span> + </div> + <div class="h-8 rounded-lg bg-slate-100 p-1"> + <div class="@GetFillClass(stage)" style="width: @width%; background-color: @(stage.Value > 0 ? stage.Color : "transparent");"> + @if (stage.Value > 0) + { + <span class="truncate px-2">@stage.Label</span> + } + </div> + </div> + </div> + } + + <div class="rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-500"> + @AppLocalization.Text("report.funnelBaselineNote", "Width is measured against {label} ({value}). Zero-value stages keep an empty track with no colored fill.") + .Replace("{label}", Items.First().Label.ToLowerInvariant()) + .Replace("{value}", AppLocalization.Number(Baseline, "0")) + </div> + } +</div> + +@code { + [Parameter] public IReadOnlyList<ReportChartItem> Items { get; set; } = Array.Empty<ReportChartItem>(); + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noFunnelData", "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 AppLocalization.Text("report.noStage", "No {stage}").Replace("{stage}", item.Label.ToLowerInvariant()); + } + + return item.Detail ?? string.Empty; + } + + private static string BuildTitle(ReportChartItem item, double percent) + { + return AppLocalization.Text("report.funnelTooltip", "{label}: {value} ({percent}% of baseline)") + .Replace("{label}", item.Label) + .Replace("{value}", AppLocalization.Number(item.Value, "0")) + .Replace("{percent}", AppLocalization.Number(percent, "0")); + } +} 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..0ebc2567c9daffef8640fc59d7bcc3c34b9712bb --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/InsightPanel.razor @@ -0,0 +1,15 @@ +<div class="@PanelClass"> + <h3 class="text-sm font-semibold text-slate-900">@Title</h3> + <div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3"> + @foreach (var insight in Insights) + { + <div class="rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-700">@insight</div> + } + </div> +</div> + +@code { + [Parameter] public string Title { get; set; } = AppLocalization.Text("report.keyInsights", "Key Insights"); + [Parameter] public IReadOnlyList<string> Insights { get; set; } = Array.Empty<string>(); + [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..2fbfb4d7fc110db617b0a2ba75232333c5fe3a07 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/MetricComparisonCard.razor @@ -0,0 +1,26 @@ +<ReportChartCard Title="@Title" Subtitle="@Subtitle" Value="@PrimaryValue" ValueLabel="@PrimaryLabel" Insight="@Insight"> + <div class="space-y-4"> + <div class="grid grid-cols-2 gap-3"> + <div class="rounded-xl border border-slate-100 bg-slate-50 p-3"> + <div class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@PrimaryLabel</div> + <div class="mt-1 text-2xl font-bold tabular-nums text-slate-950">@PrimaryValue</div> + </div> + <div class="rounded-xl border border-slate-100 bg-slate-50 p-3"> + <div class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@SecondaryLabel</div> + <div class="mt-1 text-2xl font-bold tabular-nums text-slate-950">@SecondaryValue</div> + </div> + </div> + <StackedProgressBar Segments="Segments" EmptyText="@AppLocalization.Text("report.noComparisonData", "No comparison data available.")" /> + </div> +</ReportChartCard> + +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string PrimaryLabel { get; set; } = AppLocalization.Text("report.actual", "Actual"); + [Parameter] public string PrimaryValue { get; set; } = "0"; + [Parameter] public string SecondaryLabel { get; set; } = AppLocalization.Text("report.estimated", "Estimated"); + [Parameter] public string SecondaryValue { get; set; } = "0"; + [Parameter] public string? Insight { get; set; } + [Parameter] public IReadOnlyList<StackedSegment> Segments { get; set; } = Array.Empty<StackedSegment>(); +} 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..3304b19225f634337a891a766c2935314362846e --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RadialMetricCard.razor @@ -0,0 +1,37 @@ +<ReportChartCard Title="@Title" Subtitle="@Subtitle" Value="@Value" ValueLabel="@ValueLabel" Insight="@Insight"> + <div class="flex items-center justify-center"> + <div class="relative h-36 w-36"> + <svg viewBox="0 0 160 160" class="h-36 w-36 -rotate-90" role="img" aria-label="@($"{Title}: {Percent:0}%")"> + <circle cx="80" cy="80" r="58" fill="none" stroke="#e2e8f0" stroke-width="16" /> + <circle cx="80" cy="80" r="58" fill="none" stroke="@Color" stroke-width="16" stroke-dasharray="@DashArray" stroke-linecap="round" /> + </svg> + <div class="absolute inset-0 flex flex-col items-center justify-center"> + <span class="text-3xl font-bold tabular-nums text-slate-950">@AppLocalization.Number(ClampedPercent, "0")%</span> + <span class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@CenterLabel</span> + </div> + </div> + </div> +</ReportChartCard> + +@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; } = AppLocalization.Text("common.score", "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 @@ +<section class="@CardClass"> + <div class="flex items-start justify-between gap-4"> + <div class="min-w-0"> + <h3 class="truncate text-sm font-semibold text-slate-950">@Title</h3> + @if (!string.IsNullOrWhiteSpace(Subtitle)) + { + <p class="mt-1 text-xs leading-5 text-slate-500">@Subtitle</p> + } + </div> + @if (!string.IsNullOrWhiteSpace(Value)) + { + <div class="shrink-0 text-right"> + <div class="text-2xl font-bold tabular-nums text-slate-950">@Value</div> + @if (!string.IsNullOrWhiteSpace(ValueLabel)) + { + <div class="text-[11px] font-semibold uppercase tracking-wider text-slate-400">@ValueLabel</div> + } + </div> + } + </div> + + <div class="@BodyClass"> + @ChildContent + </div> + + @if (!string.IsNullOrWhiteSpace(Insight)) + { + <div class="mt-4 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600"> + @Insight + </div> + } +</section> + +@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 @@ +<div class="@PanelClass"> + @ChildContent +</div> + +@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 + +<div class="@GridClass"> + @foreach (var metric in Metrics) + { + <KpiCard Icon="@metric.Icon" + Value="@metric.Value" + Label="@metric.Label" + BadgeText="@metric.BadgeText" + IconWrapClass="@metric.IconWrapClass" + BadgeClass="@metric.BadgeClass" + CardClass="@CardClass" /> + } +</div> + +@code { + [Parameter] public IReadOnlyList<DashboardMetric> Metrics { get; set; } = Array.Empty<DashboardMetric>(); + [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 @@ +<div class="flex items-center space-x-3"> + <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"> + <button @onclick="() => OnViewChanged.InvokeAsync(false)" class="@ListButtonClass"> + <i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View") + </button> + <button @onclick="() => OnViewChanged.InvokeAsync(true)" class="@ChartButtonClass"> + <i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View") + </button> + </div> + <button @onclick="OnExportExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel") + </button> + <button @onclick="OnExportPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF") + </button> +</div> + +@code { + [Parameter] public bool IsChartView { get; set; } + [Parameter] public EventCallback<bool> 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..5db68feb11284c880050439e6a71cc132152708c --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/RiskMatrixCard.razor @@ -0,0 +1,129 @@ +<ReportChartCard Title="@Title" Subtitle="@Subtitle" Insight="@Insight"> + @if (!Points.Any()) + { + <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <div class="space-y-3"> + <div class="grid grid-cols-[72px_repeat(3,minmax(0,1fr))] items-center gap-2"> + <div></div> + @foreach (var label in UrgencyLabels) + { + <div class="rounded-lg bg-slate-50 px-2 py-1.5 text-center text-[11px] font-semibold uppercase tracking-wider text-slate-500">@label</div> + } + </div> + + <div class="grid grid-cols-[72px_minmax(0,1fr)] gap-2"> + <div class="flex items-center justify-center rounded-lg bg-slate-50 px-2 text-center text-[11px] font-semibold uppercase tracking-wider text-slate-500 [writing-mode:vertical-rl] rotate-180"> + @SeverityAxisLabel + </div> + <div class="grid min-h-[260px] grid-cols-3 grid-rows-3 gap-2 rounded-xl border border-slate-100 bg-slate-50 p-2" role="img" aria-label="@Title"> + @for (var y = 3; y >= 1; y--) + { + @for (var x = 1; x <= 3; x++) + { + var points = Points.Where(point => point.Severity == x && point.Urgency == y).ToList(); + var total = points.Sum(point => point.Value); + var title = BuildCellTitle(points, x, y, total); + <div class="@GetCellClass(x, y, total)" title="@title"> + <span class="@GetCountClass(total)">@AppLocalization.Number(total, "0")</span> + <span class="@GetLabelClass(total)">@GetCellLabel(x, y, total)</span> + @if (total > 0) + { + <span class="mt-1 truncate text-[11px] text-slate-500">@BuildCellSummary(points)</span> + } + </div> + } + } + </div> + </div> + + <div class="grid grid-cols-[72px_repeat(3,minmax(0,1fr))] items-center gap-2"> + <div></div> + @foreach (var label in SeverityLabels) + { + <div class="text-center text-[11px] font-medium text-slate-400">@label</div> + } + </div> + + <div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-[11px] text-slate-500 font-sans"> + <span class="font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("report.legend", "Legend")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-emerald-400"></span>@AppLocalization.Text("report.healthGood", "Healthy")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-blue-400"></span>@AppLocalization.Text("report.healthWatch", "Watch")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-amber-400"></span>@AppLocalization.Text("report.healthAtRisk", "At risk")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-rose-400"></span>@AppLocalization.Text("report.healthCritical", "Critical")</span> + </div> + </div> + } +</ReportChartCard> + +@code { + [Parameter] public string Title { get; set; } = AppLocalization.Text("report.riskMatrix", "Risk matrix"); + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string? Insight { get; set; } + [Parameter] public IReadOnlyList<RiskMatrixPoint> Points { get; set; } = Array.Empty<RiskMatrixPoint>(); + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noMatrixData", "No matrix data available."); + [Parameter] public string SeverityAxisLabel { get; set; } = AppLocalization.Text("report.riskSeverity", "Risk severity"); + [Parameter] public IReadOnlyList<string> UrgencyLabels { get; set; } = new[] { AppLocalization.Text("report.lowUrgency", "Low urgency"), AppLocalization.Text("report.medium", "Medium"), AppLocalization.Text("report.highUrgency", "High urgency") }; + [Parameter] public IReadOnlyList<string> SeverityLabels { get; set; } = new[] { AppLocalization.Text("report.lowRisk", "Low risk"), AppLocalization.Text("report.medium", "Medium"), AppLocalization.Text("report.highRisk", "High risk") }; + + private static string GetCellLabel(int severity, int urgency, double total) + { + if (total <= 0) + { + return AppLocalization.Text("report.noProjects", "No projects"); + } + + return (severity, urgency) switch + { + (3, 3) => AppLocalization.Text("report.healthCritical", "Critical"), + (3, _) or (_, 3) => AppLocalization.Text("report.healthAtRisk", "At risk"), + (1, 1) => AppLocalization.Text("report.healthGood", "Healthy"), + _ => AppLocalization.Text("report.healthWatch", "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<RiskMatrixPoint> points) + { + return string.Join(", ", points.OrderByDescending(point => point.Value).Take(2).Select(point => point.Label)); + } + + private static string BuildCellTitle(IReadOnlyList<RiskMatrixPoint> points, int severity, int urgency, double total) + { + if (total <= 0) + { + return $"{AppLocalization.Text("report.noProjects", "No projects")}. {AppLocalization.Text("report.riskSeverity", "Severity")} {AppLocalization.Number(severity)}, {AppLocalization.Text("report.urgencyLabel", "urgency")} {AppLocalization.Number(urgency)}."; + } + + var names = string.Join(Environment.NewLine, points.OrderByDescending(point => point.Value).Select(point => $"{point.Label}: {point.Detail ?? $"{AppLocalization.Number(point.Value)} {AppLocalization.Text("report.itemsCount", "item(s)")}"}")); + return $"{AppLocalization.Number(total)} {AppLocalization.Text("report.projectsCount", "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..009c14da3401396b4266e703f1e89b9635bfd273 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/StackedProgressBar.razor @@ -0,0 +1,36 @@ +<div class="space-y-3"> + @if (!Segments.Any() || Total <= 0) + { + <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-8 text-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <div class="flex h-4 overflow-hidden rounded-full bg-slate-100" role="img" aria-label="@AriaLabel"> + @foreach (var segment in Segments) + { + var width = segment.Value / Total * 100; + <div title="@($"{segment.Label}: {segment.Value:0}")" class="h-full" style="width: @width%; background-color: @segment.Color;"></div> + } + </div> + <div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> + @foreach (var segment in Segments) + { + var pct = segment.Value / Total * 100; + <div class="flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-3 py-2"> + <span class="flex min-w-0 items-center gap-2 text-xs font-medium text-slate-700"> + <span class="h-2.5 w-2.5 shrink-0 rounded-full" style="background-color: @segment.Color"></span> + <span class="truncate">@segment.Label</span> + </span> + <span class="text-xs font-semibold tabular-nums text-slate-900">@AppLocalization.Number(segment.Value, "0.#") · @AppLocalization.Number(pct, "0")%</span> + </div> + } + </div> + } +</div> + +@code { + [Parameter] public IReadOnlyList<StackedSegment> Segments { get; set; } = Array.Empty<StackedSegment>(); + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noChartData", "No data available."); + [Parameter] public string AriaLabel { get; set; } = AppLocalization.Text("report.stackedProgress", "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..6a3b80c7931846ef3c3de65ee667d0b436ede5f3 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TopRiskList.razor @@ -0,0 +1,52 @@ +<div class="space-y-3"> + @if (!Items.Any()) + { + <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div> + } + else + { + @foreach (var item in Items.Take(Limit)) + { + <div class="rounded-xl border border-slate-100 bg-slate-50 px-4 py-3"> + <div class="flex items-start justify-between gap-3"> + <div class="min-w-0"> + <div class="truncate text-sm font-semibold text-slate-950">@item.Title</div> + <div class="mt-1 text-xs text-slate-500">@item.Meta</div> + </div> + <div class="shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold @GetBadgeClass(item.Tone)">@item.Badge</div> + </div> + <div class="mt-3 h-2 overflow-hidden rounded-full bg-white"> + <div class="h-full rounded-full @GetBarClass(item.Tone)" style="width: @Math.Clamp(item.Score, 0, 100)%"></div> + </div> + @if (!string.IsNullOrWhiteSpace(item.Detail)) + { + <p class="mt-2 text-xs text-slate-500">@item.Detail</p> + } + </div> + } + } +</div> + +@code { + [Parameter] public IReadOnlyList<TopRiskItem> Items { get; set; } = Array.Empty<TopRiskItem>(); + [Parameter] public int Limit { get; set; } = 5; + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noRiskItems", "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..8af93516c26ce257dbbac1f8d00fa3101ab0703a --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/TrendSparklineCard.razor @@ -0,0 +1,50 @@ +<ReportChartCard Title="@Title" Subtitle="@Subtitle" Value="@Value" ValueLabel="@ValueLabel" Insight="@Insight"> + @if (Points.Count < 2) + { + <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <svg viewBox="0 0 320 120" class="h-28 w-full" role="img" aria-label="@Title"> + <path d="@AreaPath" fill="@Color" opacity="0.12" /> + <path d="@LinePath" fill="none" stroke="@Color" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> + @foreach (var point in SvgPoints) + { + <circle cx="@point.X" cy="@point.Y" r="4" fill="white" stroke="@Color" stroke-width="3" /> + } + </svg> + <div class="mt-2 flex justify-between text-[11px] font-medium text-slate-400"> + <span>@Points.First().Label</span> + <span>@Points.Last().Label</span> + </div> + } +</ReportChartCard> + +@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<SparklinePoint> Points { get; set; } = Array.Empty<SparklinePoint>(); + [Parameter] public string Color { get; set; } = "#7c3aed"; + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.notEnoughTrendData", "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..c916d26bdd37caf68ab60251510a111cb03b6876 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/Components/WorkloadHeatmap.razor @@ -0,0 +1,55 @@ +<ReportChartCard Title="@Title" Subtitle="@Subtitle" Insight="@Insight"> + @if (!Rows.Any() || !Columns.Any()) + { + <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <div class="overflow-x-auto"> + <div class="min-w-[520px]"> + <div class="grid gap-2" style="grid-template-columns: 150px repeat(@Columns.Count, minmax(72px, 1fr));"> + <div></div> + @foreach (var column in Columns) + { + <div class="text-center text-[11px] font-semibold uppercase tracking-wider text-slate-400">@column</div> + } + @foreach (var row in Rows) + { + <div class="truncate py-2 text-xs font-semibold text-slate-700">@row</div> + @foreach (var column in Columns) + { + var cell = Cells.FirstOrDefault(item => item.Row == row && item.Column == column); + var value = cell?.Value ?? 0; + <div class="@GetCellClass(value)" title="@cell?.Detail"> + <span class="text-sm font-bold tabular-nums">@AppLocalization.Number(value, "0.#")</span> + </div> + } + } + </div> + </div> + </div> + } +</ReportChartCard> + +@code { + [Parameter] public string Title { get; set; } = AppLocalization.Text("report.workloadHeatmap", "Workload heatmap"); + [Parameter] public string? Subtitle { get; set; } + [Parameter] public string? Insight { get; set; } + [Parameter] public IReadOnlyList<string> Rows { get; set; } = Array.Empty<string>(); + [Parameter] public IReadOnlyList<string> Columns { get; set; } = Array.Empty<string>(); + [Parameter] public IReadOnlyList<HeatmapCell> Cells { get; set; } = Array.Empty<HeatmapCell>(); + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noWorkloadData", "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..19c20c0722147fde76c069bb87de7d6e8c10f3a9 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/EmployeeReport.razor @@ -0,0 +1,555 @@ +@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 + +<PageTitle>@AppLocalization.Text("report.employeeWorkloadTitle", "Employee Workload & Performance")</PageTitle> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("report.employeeWorkloadHeading", "Employee Workload & Performance")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("report.employeeWorkloadDesc", "Monitor issue workload, execution health, and actual effort.")</p> + </div> + <div class="flex items-center space-x-3"> + <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"> + <button @onclick="() => SetView(false)" class="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"> + <i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View") + </button> + <button @onclick="() => SetView(true)" class="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"> + <i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View") + </button> + </div> + <button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel") + </button> + <button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF") + </button> + </div> + </div> + + <!-- Filters --> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex-1 min-w-[200px]"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.searchEmployee", "Search Employee")</label> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchByName", "Search by name...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.periodFilter", "Period Filter")</label> + <select @bind="filterPeriod" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="all">@AppLocalization.Text("common.all", "All Time")</option> + <option value="daily">@AppLocalization.Text("report.daily", "Daily (Today)")</option> + <option value="monthly">@AppLocalization.Text("report.monthly", "Monthly (This Month)")</option> + <option value="yearly">@AppLocalization.Text("report.yearly", "Yearly (This Year)")</option> + <option value="custom">@AppLocalization.Text("report.customRange", "Custom Range")</option> + </select> + </div> + + @if (filterPeriod == "custom") + { + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date")</label> + <input @bind="filterStartDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.endDate", "End Date")</label> + <input @bind="filterEndDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + } + + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.status", "Task Status")</label> + <select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allTasks", "All Tasks")</option> + <option value="1">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="2">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="3">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + <option value="overdue">@AppLocalization.Text("status.overdue", "Overdue")</option> + </select> + </div> + <button @onclick="ApplyAllFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetAllFilters" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + + <div class="flex flex-wrap items-center gap-6 w-full border-t border-slate-100 pt-3 mt-1"> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMe" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMe", "Assigned to me")</span> + </label> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMyTeam" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMyTeam", "Assigned to my team")</span> + </label> + </div> + </div> + </div> + + <!-- Content Area --> + @if (isLoading) + { + <div class="flex items-center justify-center py-20"> + <div class="flex items-center space-x-3 text-slate-500"> + <svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> + <span class="text-sm font-medium">@AppLocalization.Text("common.loading", "Loading data...")</span> + </div> + </div> + } + else + { + @if (isChartView) + { + <div class="grid grid-cols-1 gap-5 xl:grid-cols-12"> + <div class="xl:col-span-7"> + <WorkloadHeatmap Title="@AppLocalization.Text("report.workloadHeatmap", "Workload heatmap")" Subtitle="@AppLocalization.Text("report.workloadHeatmapDesc", "Capacity signals across assigned work, open issues, overdue issues, and hours.")" Rows="EmployeeHeatmapRows" Columns="EmployeeHeatmapColumns" Cells="EmployeeHeatmapCells" Insight="@AppLocalization.Text("report.workloadHeatmapInsight", "Darker cells mean someone may need support or scope adjustment.")" /> + </div> + <div class="xl:col-span-5"> + <ReportChartCard Title="@AppLocalization.Text("report.capacitySupportList", "Capacity support list")" Subtitle="@AppLocalization.Text("report.capacitySupportDesc", "Highest support signals, phrased as capacity risk.")" Insight="@AppLocalization.Text("report.capacitySupportInsight", "Use this to route help and remove blockers, not to rank people negatively.")"> + <TopRiskList Items="EmployeeCapacityRiskItems" EmptyText="@AppLocalization.Text("report.noCapacityRisks", "No capacity risks in the current filter.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-5"> + <ReportChartCard Title="@AppLocalization.Text("report.completedVsOpenIssues", "Completed vs open issues")" Subtitle="@AppLocalization.Text("report.teamExecutionBalance", "Team execution balance by issue state.")" Value="@AppLocalization.Number(CompletedIssueTotal)" ValueLabel="@AppLocalization.Text("report.completedTasks", "completed tasks")"> + <StackedProgressBar Segments="EmployeeCompletionSegments" AriaLabel="Completed versus open employee workload" /> + </ReportChartCard> + </div> + <div class="xl:col-span-4"> + <ReportChartCard Title="@AppLocalization.Text("report.overdueSupport", "Overdue support")" Subtitle="@AppLocalization.Text("report.employeesWithOverdueLoad", "Employees with overdue issue load.")" Insight="@AppLocalization.Text("report.overdueSupportInsight", "Treat overdue counts as support requests.")"> + <TopRiskList Items="EmployeeOverdueItems" EmptyText="@AppLocalization.Text("report.noOverdueSupport", "No overdue support needs found.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-3"> + <TrendSparklineCard Title="@AppLocalization.Text("report.completionPattern", "Completion pattern")" Subtitle="@AppLocalization.Text("report.completionPatternDesc", "Completion rate across visible employees.")" Value="@($"{AppLocalization.Number(AverageCompletionRate, "0")}%")" ValueLabel="@AppLocalization.Text("report.avgRate", "avg rate")" Points="EmployeeCompletionSparkline" Color="#3b82f6" Insight="@AppLocalization.Text("report.completionPatternInsight", "A quick view of whether completion is clustered or balanced.")" /> + </div> + </div> + } + else + { + <!-- List View --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.employee", "Employee")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.assignedIssues", "Assigned Issues")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.openOverdue", "Open / Overdue")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actualHours", "Actual Hours")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.workload", "Workload")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.trend", "Trend")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (pagedEmployees.Any()) + { + @foreach (var entry in pagedEmployees.Select((emp, index) => (emp, index))) + { + var emp = entry.emp; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4"> + <div class="flex items-center space-x-3"> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-700 font-semibold text-xs"> + @(GetEmployeeName(emp).Length > 0 ? GetEmployeeName(emp)[0].ToString().ToUpper() : "U") + </span> + <div> + <p class="text-sm font-semibold text-slate-900">@GetEmployeeName(emp)</p> + <p class="text-xs text-slate-400">@@@emp.Username</p> + </div> + </div> + </td> + <td class="px-6 py-4 text-sm font-medium text-slate-900">@AppLocalization.Number(emp.AssignedIssues)</td> + <td class="px-6 py-4 text-sm font-medium"><span class="text-blue-600">@AppLocalization.Number(emp.OpenIssues)</span> / <span class="text-rose-600">@AppLocalization.Number(emp.OverdueIssues)</span></td> + <td class="px-6 py-4 text-sm font-semibold text-slate-800">@AppLocalization.Number(emp.ActualHours, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs")</td> + <td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetWorkloadBadge(emp.WorkloadLevel)">@GetWorkloadLabel(emp.WorkloadLevel)</span></td> + <td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetTrendBadge(emp.Trend)">@GetTrendLabel(emp.Trend)</span></td> + </tr> + } + } + else + { + <tr> + <td colspan="7" class="px-6 py-12 text-center"> + <p class="text-sm text-slate-400">@AppLocalization.Text("report.noEmployeeData", "No employee data found matching criteria.")</p> + </td> + </tr> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="TotalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } + } +</div> + +@code { + private bool isLoading = true; + private bool isChartView = false; + private List<EmployeeProductivityReportDto> allEmployees = new(); + private List<EmployeeProductivityReportDto> pagedEmployees = new(); + private PagedResult<EmployeeProductivityReportDto>? 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 string GetEmployeeName(EmployeeProductivityReportDto employee) => Content.GetText(employee.FullName, employee.FullNameMy); + + private List<string> EmployeeHeatmapRows => allEmployees.Take(6).Select(GetEmployeeName).ToList(); + private List<string> EmployeeHeatmapColumns => new() + { + AssignedColumnLabel, + OpenColumnLabel, + OverdueColumnLabel, + HoursColumnLabel + }; + private string AssignedColumnLabel => AppLocalization.Text("report.assignedColumn", "Assigned"); + private string OpenColumnLabel => AppLocalization.Text("report.openColumn", "Open"); + private string OverdueColumnLabel => AppLocalization.Text("report.overdueColumn", "Overdue"); + private string HoursColumnLabel => AppLocalization.Text("common.hours", "Hours"); + private int CompletedIssueTotal => allEmployees.Sum(emp => emp.CompletedCount); + private double AverageCompletionRate => allEmployees.Any() ? allEmployees.Average(emp => emp.CompletionRate) : 0; + + private List<HeatmapCell> EmployeeHeatmapCells => allEmployees.Take(6) + .SelectMany(emp => new[] + { + new HeatmapCell(GetEmployeeName(emp), AssignedColumnLabel, Math.Min(100, emp.AssignedCount * 8), $"{AppLocalization.Number(emp.AssignedCount)} {AppLocalization.Text("report.assignedTasks", "assigned tasks").ToLowerInvariant()}"), + new HeatmapCell(GetEmployeeName(emp), OpenColumnLabel, Math.Min(100, emp.OpenIssues * 12), $"{AppLocalization.Number(emp.OpenIssues)} {AppLocalization.Text("report.openIssuesSegment", "open issues").ToLowerInvariant()}"), + new HeatmapCell(GetEmployeeName(emp), OverdueColumnLabel, Math.Min(100, emp.OverdueIssues * 20), $"{AppLocalization.Number(emp.OverdueIssues)} {AppLocalization.Text("report.overdueIssuesSegment", "overdue issues").ToLowerInvariant()}"), + new HeatmapCell(GetEmployeeName(emp), HoursColumnLabel, Math.Min(100, (double)emp.ActualHours * 5), $"{AppLocalization.Number(emp.ActualHours, "N1")} {AppLocalization.Text("common.actualHours", "actual hours").ToLowerInvariant()}") + }) + .ToList(); + + private List<TopRiskItem> 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"; + var workload = AppLocalization.Text($"priority.{emp.WorkloadLevel.ToLower()}", emp.WorkloadLevel); + return new TopRiskItem(GetEmployeeName(emp), $"{workload} · {AppLocalization.Number(emp.ActualHours, "N1")}h {AppLocalization.Text("report.tracked", "tracked")}", score, AppLocalization.Text("report.needsSupport", "needs support"), tone, $"{AppLocalization.Number(emp.OpenIssues)} {AppLocalization.Text("report.open", "open")} · {AppLocalization.Number(emp.OverdueIssues)} {AppLocalization.Text("status.overdue", "overdue")}"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List<StackedSegment> EmployeeCompletionSegments => new() + { + new(AppLocalization.Text("report.completedTasks", "Completed tasks"), allEmployees.Sum(emp => emp.CompletedCount), "#10b981"), + new(AppLocalization.Text("report.assignedTasks", "Assigned tasks"), allEmployees.Sum(emp => Math.Max(0, emp.AssignedCount - emp.CompletedCount)), "#3b82f6"), + new(AppLocalization.Text("report.openIssuesSegment", "Open issues"), allEmployees.Sum(emp => emp.OpenIssues), "#f59e0b") + }; + + private List<TopRiskItem> EmployeeOverdueItems => allEmployees + .Where(emp => emp.OverdueIssues > 0) + .OrderByDescending(emp => emp.OverdueIssues) + .Take(5) + .Select(emp => new TopRiskItem(GetEmployeeName(emp), $"{AppLocalization.Number(emp.OpenIssues)} {AppLocalization.Text("report.openIssuesSegment", "open issues")}", Math.Min(100, emp.OverdueIssues * 25), $"{AppLocalization.Number(emp.OverdueIssues)} {AppLocalization.Text("status.overdue", "overdue")}", "rose", AppLocalization.Text("report.capacityRisk", "Capacity risk"))) + .ToList(); + + private List<SparklinePoint> EmployeeCompletionSparkline => allEmployees + .OrderByDescending(emp => emp.AssignedCount) + .Take(8) + .Select(emp => new SparklinePoint(BuildShortName(GetEmployeeName(emp)), emp.CompletionRate)) + .DefaultIfEmpty(new SparklinePoint(AppLocalization.Text("common.noDataYet", "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() + { + isLoading = true; + allEmployees = new(); + pagedEmployees = new(); + reportPage = null; + + var client = ApiClient.CreateClient(); + try + { + var parts = new List<string>(); + 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<Result<List<EmployeeProductivityReportDto>>>(fullUrl, Serialization.CaseInsensitive); + if (fullResult != null && fullResult.IsSuccess && fullResult.Value != null) + { + allEmployees = fullResult.Value + .Where(employee => MatchesEmployeeSearch(employee, searchQuery)) + .ToList(); + } + + // 2. Fetch paged list for table pagination + var pagedParts = new List<string>(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<PagedResult<EmployeeProductivityReportDto>>(pagedUrl, Serialization.CaseInsensitive); + if (pagedResult != null) + { + if (string.IsNullOrWhiteSpace(searchQuery)) + { + reportPage = pagedResult; + pagedEmployees = pagedResult.Items.ToList(); + currentPage = pagedResult.Page; + pageSize = pagedResult.PageSize; + } + else + { + var totalCount = allEmployees.Count; + var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); + currentPage = totalPages > 0 ? Math.Min(currentPage, totalPages) : 1; + pagedEmployees = allEmployees + .Skip((currentPage - 1) * pageSize) + .Take(pageSize) + .ToList(); + reportPage = new PagedResult<EmployeeProductivityReportDto> + { + Items = pagedEmployees, + TotalCount = totalCount, + Page = currentPage, + PageSize = pageSize, + TotalPages = totalPages + }; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading employee report: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private static bool MatchesEmployeeSearch(EmployeeProductivityReportDto employee, string? search) + { + if (string.IsNullOrWhiteSpace(search)) + { + return true; + } + + var term = search.Trim(); + return ContainsSearch(employee.FullName, term) || + ContainsSearch(employee.FullNameMy, term) || + ContainsSearch(employee.Username, term); + } + + private static bool ContainsSearch(string? value, string search) + => !string.IsNullOrWhiteSpace(value) && value.Contains(search, StringComparison.OrdinalIgnoreCase); + + private async Task DownloadExcel() + { + var rows = allEmployees.Select(emp => new object?[] + { + GetEmployeeName(emp), + 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<string> + { + $"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[] + { + GetEmployeeName(emp), + 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 GetWorkloadLabel(string workload) => workload switch + { + "High" => AppLocalization.Text("report.workloadHigh", "High"), + "Light" => AppLocalization.Text("report.workloadLight", "Light"), + _ => AppLocalization.Text("report.workloadBalanced", "Balanced") + }; + + 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 GetTrendLabel(string trend) => trend switch + { + "Improving" => AppLocalization.Text("report.trendImproving", "Improving"), + "Declining" => AppLocalization.Text("report.trendDeclining", "Declining"), + _ => AppLocalization.Text("report.trendStable", "Stable") + }; + + 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..03588ac40481bf7de9b2c135225fcbf704e50437 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/IssueReport.razor @@ -0,0 +1,419 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("issueReport", "Issue Report")</PageTitle> + +<div class="space-y-6"> + <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("issueReport", "Issue Report")</h2> + <p class="mt-1 text-slate-500">@AppLocalization.PageDescription("issueReport", "Track daily execution, backlog health, and issue-level effort.")</p> + </div> + <div class="flex flex-wrap items-center gap-3"> + <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"> + <button @onclick="() => SetView(false)" class="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")"> + <i data-lucide="list" class="mr-1 h-3.5 w-3.5"></i> @AppLocalization.Text("common.listView", "List View") + </button> + <button @onclick="() => SetView(true)" class="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")"> + <i data-lucide="bar-chart-3" class="mr-1 h-3.5 w-3.5"></i> @AppLocalization.Text("common.chartView", "Chart View") + </button> + </div> + <button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"> + <i data-lucide="download" class="mr-2 h-4 w-4"></i> @AppLocalization.Text("common.exportExcel", "Export Excel") + </button> + <button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"> + <i data-lucide="file-text" class="mr-2 h-4 w-4"></i> @AppLocalization.Text("common.exportPdf", "Export PDF") + </button> + </div> + </div> + + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"> + <div class="flex flex-wrap items-end gap-3"> + <div class="min-w-[220px] flex-1"> + <label class="mb-1.5 block text-xs font-medium text-slate-500">@AppLocalization.Text("common.search", "Search")</label> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchIssues", "Search issue, task, project, assignee...")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + </div> + <div> + <label class="mb-1.5 block text-xs font-medium text-slate-500">@AppLocalization.Text("common.project", "Project")</label> + <select @bind="filterProjectId" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600"> + <option value="0">@AppLocalization.Text("common.all", "All Projects")</option> + @foreach (var project in projects) + { + <option value="@project.Id">@Content.GetProjectName(project)</option> + } + </select> + </div> + <div> + <label class="mb-1.5 block text-xs font-medium text-slate-500">@AppLocalization.Text("common.status", "Status")</label> + <select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600"> + <option value="0">@AppLocalization.Text("common.all", "All Statuses")</option> + <option value="1">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="2">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="3">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + <option value="overdue">@AppLocalization.Text("status.overdue", "Overdue")</option> + <option value="blocked">@AppLocalization.Text("status.blocked", "Blocked")</option> + </select> + </div> + <button @onclick="ApplyFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-violet-700"> + <i data-lucide="search" class="mr-2 h-4 w-4"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetFilters" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 shadow-sm hover:bg-slate-50"> + <i data-lucide="rotate-ccw" class="h-4 w-4"></i> + </button> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("report.loadingIssueReport", "Loading issue report...")" /> + } + else + { + <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> + @MetricCard(AppLocalization.Text("report.totalIssues", "Total Issues"), AppLocalization.Number(FilteredIssues.Count()), "file-pen-line", "violet") + @MetricCard(AppLocalization.Text("report.openIssues", "Open Issues"), AppLocalization.Number(OpenIssues), "list-todo", "blue") + @MetricCard(AppLocalization.Text("report.overdueIssues", "Overdue Issues"), AppLocalization.Number(OverdueIssues), "alarm-clock", "rose") + @MetricCard(AppLocalization.Text("report.blockedIssues", "Blocked Issues"), AppLocalization.Number(BlockedIssues), "ban", "amber") + </div> + + <div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm"> + <h3 class="text-sm font-semibold text-slate-900">@AppLocalization.Text("report.keyInsights", "Key Insights")</h3> + <div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3"> + @foreach (var insight in Insights) + { + <div class="rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-sm text-slate-700">@insight</div> + } + </div> + </div> + + <div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm"> + @if (isChartView) + { + <div class="grid grid-cols-1 gap-5 xl:grid-cols-12"> + <div class="xl:col-span-5"> + <ReportChartCard Title="@AppLocalization.Text("report.issueLifecycleFunnel", "Issue lifecycle funnel")" Subtitle="@AppLocalization.Text("report.issueLifecycleFunnelDesc", "How issues move from backlog to resolution.")" Insight="@AppLocalization.Text("report.issueLifecycleInsight", "A large in-progress or blocked band is a daily execution signal.")"> + <FunnelChart Items="IssueFunnelItems" /> + </ReportChartCard> + </div> + <div class="xl:col-span-4"> + <ReportChartCard Title="@AppLocalization.Text("report.issueStatusDonut", "Issue status donut")" Subtitle="@AppLocalization.Text("report.issueStatusDonutDesc", "Current status distribution, including overdue pressure.")" Insight="@IssueStatusInsight"> + <DonutStatusChart Items="IssueStatusItems" CenterLabel="@AppLocalization.Text("common.issue", "issues")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-3"> + <RadialMetricCard Title="@AppLocalization.Text("report.blockedOverdue", "Blocked / overdue")" Subtitle="@AppLocalization.Text("report.executionRisk", "Execution risk in the filtered issue set.")" Percent="@BlockedOverduePercent" Value="@AppLocalization.Number(BlockedIssues + OverdueIssues)" ValueLabel="@AppLocalization.Text("report.riskIssues", "risk issues")" CenterLabel="@AppLocalization.Text("report.risk", "risk")" Color="#ef4444" Insight="@AppLocalization.Text("report.focusStandup", "Focus standup discussion here first.")" /> + </div> + <div class="xl:col-span-4"> + <ReportChartCard Title="@AppLocalization.Text("report.issuesByProject", "Issues by project")" Subtitle="@AppLocalization.Text("report.issuesByProjectDesc", "Where issue volume is concentrated.")" Insight="@AppLocalization.Text("report.issuesByProjectInsight", "Useful for project-level triage and planning health.")"> + <TopRiskList Items="IssueProjectItems" EmptyText="@AppLocalization.Text("common.noResults", "No project issues found.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-4"> + <ReportChartCard Title="@AppLocalization.Text("report.issuesByAssignee", "Issues by assignee")" Subtitle="@AppLocalization.Text("report.issuesByAssigneeDesc", "Support needs by assignee.")" Insight="@AppLocalization.Text("report.issuesByAssigneeInsight", "Use as a support signal, especially when overdue or blocked items cluster.")"> + <TopRiskList Items="IssueAssigneeItems" EmptyText="@AppLocalization.Text("common.noResults", "No assignee issues found.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-4"> + <ReportChartCard Title="@AppLocalization.Text("report.resolutionAging", "Resolution aging")" Subtitle="@AppLocalization.Text("report.resolutionAgingDesc", "How close issues are to or past due date.")" Insight="@AppLocalization.Text("report.resolutionAgingInsight", "Older overdue buckets should be cleared before new scope expands.")"> + <AgingBucketChart Buckets="IssueAgingBuckets" /> + </ReportChartCard> + </div> + </div> + } + else + { + <div class="overflow-x-hidden"> + <table class="w-full table-fixed"> + <colgroup> + <col class="w-[27%]" /> + <col class="w-[17%]" /> + <col class="w-[18%]" /> + <col class="w-[11%]" /> + <col class="w-[10%]" /> + <col class="w-[9%]" /> + <col class="w-[8%]" /> + </colgroup> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.issue", "Issue")</th> + <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.taskProject", "Task / Project")</th> + <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.assignee", "Assignee")</th> + <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.status", "Status")</th> + <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.hours", "Hours")</th> + <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.dueDate", "Due")</th> + <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">@AppLocalization.Text("common.risk", "Risk")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @foreach (var issue in PagedIssues) + { + <tr class="align-top hover:bg-slate-50/50"> + <td class="px-4 py-3"> + <button @onclick="() => ToggleExpanded(issue.Id)" class="block w-full text-left text-sm font-semibold text-slate-900 hover:text-violet-600 break-words">@Content.GetIssueTitle(issue)</button> + @if (expandedIssues.Contains(issue.Id)) + { + <div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600"> + <div>@(string.IsNullOrWhiteSpace(Content.GetIssueDescription(issue)) ? AppLocalization.Text("common.noDescription", "No description") : Content.GetIssueDescription(issue))</div> + <div class="mt-1">@AppLocalization.Text("report.delay", "Delay"): @(string.IsNullOrWhiteSpace(Content.GetText(issue.DelayReason, issue.DelayReasonMy)) ? AppLocalization.Text("common.noneRecorded", "None recorded") : Content.GetText(issue.DelayReason, issue.DelayReasonMy))</div> + <div>@AppLocalization.Text("report.blockedBy", "Blocked by"): @(string.IsNullOrWhiteSpace(Content.GetText(issue.BlockedBy, issue.BlockedByMy)) ? AppLocalization.Text("common.none", "None") : Content.GetText(issue.BlockedBy, issue.BlockedByMy))</div> + </div> + } + </td> + <td class="px-4 py-3 text-sm text-slate-600"> + <button @onclick="() => ToggleTaskDetails(issue.Id)" class="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-mono text-[11px] font-semibold tracking-wide text-indigo-700 hover:border-indigo-200 hover:bg-indigo-50"> + @GetTaskCode(issue.TaskId) + </button> + @if (expandedTaskDetails.Contains(issue.Id)) + { + <div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600"> + <div class="font-medium text-slate-800 break-words">@Content.GetText(issue.TaskTitle, issue.TaskTitleMy)</div> + <div class="mt-1 break-words">@Content.GetText(issue.ProjectName, issue.ProjectNameMy)</div> + </div> + } + </td> + <td class="px-4 py-3 text-sm text-slate-600 break-words">@(string.IsNullOrWhiteSpace(Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(issue.AssignedToName, issue.AssignedToNameMy))</td> + <td class="px-4 py-3"><span class="inline-flex whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium @GetStatusBadge(issue.StatusId)">@GetStatusLabel(issue.StatusId)</span></td> + <td class="px-4 py-3 text-sm text-slate-700 whitespace-nowrap">@AppLocalization.Number(issue.EstimatedHours ?? 0, "N1") / @AppLocalization.Number(issue.ActualHours ?? 0, "N1")</td> + <td class="px-4 py-3 text-sm whitespace-nowrap @(IsOverdue(issue) ? "font-semibold text-rose-600" : "text-slate-600")">@DisplayFormats.Date(issue.DueDate)</td> + <td class="px-4 py-3 whitespace-nowrap">@RiskBadge(issue)</td> + </tr> + } + @if (!PagedIssues.Any()) + { + <EmptyState ColSpan="7" Icon="file-pen-line" Title="@AppLocalization.Text("report.noIssuesFound", "No issues found")" Subtitle="@AppLocalization.Text("report.noIssuesFoundDesc", "No issues matched the current report filters.")" /> + } + </tbody> + </table> + </div> + <Pagination CurrentPage="currentPage" TotalPages="TotalPages" PageSize="pageSize" TotalCount="FilteredIssues.Count()" OnPageChanged="HandlePageChanged" OnPageSizeChanged="HandlePageSizeChanged" /> + } + </div> + } +</div> + +@code { + private bool isLoading = true; + private bool isChartView; + private List<IssueDto> issues = new(); + private List<ProjectDto> projects = new(); + private HashSet<long> expandedIssues = new(); + private HashSet<long> 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<IssueDto> FilteredIssues => issues.Where(MatchesFilters).OrderBy(i => i.DueDate).ThenBy(i => i.Title); + private IEnumerable<IssueDto> 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<Result<List<IssueDto>>>("Report/issues", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("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 ContainsSearch(issue.Title, issue.TitleMy, s) + || ContainsSearch(issue.TaskTitle, issue.TaskTitleMy, s) + || ContainsSearch(issue.ProjectName, issue.ProjectNameMy, s) + || ContainsSearch(issue.Description, issue.DescriptionMy, s) + || ContainsSearch(issue.DelayReason, issue.DelayReasonMy, s) + || ContainsSearch(issue.BlockedBy, issue.BlockedByMy, s) + || ContainsSearch(issue.AssignedToName, issue.AssignedToNameMy, s); + } + return true; + } + + private static bool ContainsSearch(string? english, string? burmese, string search) + => (!string.IsNullOrWhiteSpace(english) && english.Contains(search, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrWhiteSpace(burmese) && burmese.Contains(search, StringComparison.OrdinalIgnoreCase)); + + 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<string?>("localStorage.getItem", StorageKey); + if (string.IsNullOrWhiteSpace(json)) return; + var saved = System.Text.Json.JsonSerializer.Deserialize<SavedFilter>(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-{AppLocalization.LocalizeDigits(taskId.ToString("D4", System.Globalization.CultureInfo.InvariantCulture))}"; + 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<TaskPriority>().Select(p => (GetPriorityLabel(p), FilteredIssues.Count(i => i.PriorityId == p))).ToList(); + private IReadOnlyList<(string Label, int Value)> EmployeeChart => FilteredIssues.GroupBy(i => string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy)).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList(); + private IReadOnlyList<(string Label, int Value)> ProjectChart => FilteredIssues.GroupBy(i => Content.GetText(i.ProjectName, i.ProjectNameMy)).OrderByDescending(g => g.Count()).Take(5).Select(g => (g.Key, g.Count())).ToList(); + private IEnumerable<string> Insights => new[] { $"{AppLocalization.Number(OverdueIssues)} {AppLocalization.Text("report.overdueIssuesNeedAttention", "overdue issues need attention.")}", $"{AppLocalization.Number(BlockedIssues)} {AppLocalization.Text("report.issuesAreBlocked", "issues are blocked.")}", $"{AppLocalization.Text("report.actualHoursAre", "Actual hours are")} {AppLocalization.Number(FilteredIssues.Sum(i => i.ActualHours ?? 0), "N1")} {AppLocalization.Text("report.against", "against")} {AppLocalization.Number(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) + ? $"{AppLocalization.Number(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<ReportChartItem> 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<ReportChartItem> 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<TopRiskItem> IssueProjectItems => FilteredIssues + .GroupBy(i => Content.GetText(i.ProjectName, i.ProjectNameMy)) + .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, $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("common.issue", "issues")} · {AppLocalization.Number(blocked)} {AppLocalization.Text("status.blocked", "blocked")}", score, overdue > 0 ? $"{AppLocalization.Number(overdue)} {AppLocalization.Text("status.overdue", "overdue")}" : $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("common.issue", "issues")}", overdue > 0 || blocked > 0 ? "rose" : "blue"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List<TopRiskItem> IssueAssigneeItems => FilteredIssues + .GroupBy(i => string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy)) + .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, $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("report.assignedIssues", "assigned issues")}", score, blocked > 0 ? $"{AppLocalization.Number(blocked)} {AppLocalization.Text("status.blocked", "blocked")}" : $"{AppLocalization.Number(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<ReportChartItem> 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, $"<div class=\"flex items-center justify-between\"><div><p class=\"text-xs font-semibold uppercase tracking-wider text-{color}-600\">{label}</p><p class=\"mt-1 text-3xl font-bold text-{color}-700\">{value}</p></div><span class=\"flex h-11 w-11 items-center justify-center rounded-xl bg-white/70\"><i data-lucide=\"{icon}\" class=\"h-6 w-6 text-{color}-600\"></i></span></div>"); + builder.CloseElement(); + }; + + private async Task DownloadExcel() + { + var rows = FilteredIssues.Select(i => new object?[] { Content.GetText(i.Title, i.TitleMy), Content.GetText(i.TaskTitle, i.TaskTitleMy), Content.GetText(i.ProjectName, i.ProjectNameMy), string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy), 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), Content.GetText(i.DelayReason, i.DelayReasonMy) }); + 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[] { Content.GetText(i.Title, i.TitleMy), Content.GetText(i.TaskTitle, i.TaskTitleMy), Content.GetText(i.ProjectName, i.ProjectNameMy), string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy), 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..9c05fff83b3db7d01d38afbbbbca85ecbf3fba46 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/OverdueTasksReport.razor @@ -0,0 +1,572 @@ +@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 + +<PageTitle>@AppLocalization.Text("report.overdueTasksPageTitle", "Overdue & Delayed Tasks Report")</PageTitle> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("report.overdueTasksHeading", "Overdue & Delayed Tasks")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("report.overdueTasksDesc", "Track tasks that have passed their due date or are at risk of delay.")</p> + </div> + <div class="flex flex-wrap items-center gap-3"> + <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"> + <button @onclick="() => SetView(false)" class="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"> + <i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View") + </button> + <button @onclick="() => SetView(true)" class="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"> + <i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View") + </button> + </div> + <button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel") + </button> + <button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF") + </button> + </div> + </div> + + <!-- Summary Cards --> + @if (!isLoading) + { + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> + <div class="rounded-xl border border-rose-100 bg-rose-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-rose-500">@AppLocalization.Text("dashboard.overdueTasks", "Overdue Tasks")</p> + <p class="mt-1 text-3xl font-bold text-rose-700">@AppLocalization.Number(overdueCount)</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100"> + <i data-lucide="alert-circle" class="h-6 w-6 text-rose-600"></i> + </span> + </div> + </div> + <div class="rounded-xl border border-amber-100 bg-amber-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-amber-500">@AppLocalization.Text("report.highPriorityOverdue", "High Priority Overdue")</p> + <p class="mt-1 text-3xl font-bold text-amber-700">@AppLocalization.Number(criticalCount)</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100"> + <i data-lucide="flame" class="h-6 w-6 text-amber-600"></i> + </span> + </div> + </div> + <div class="rounded-xl border border-orange-100 bg-orange-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-orange-500">@AppLocalization.Text("report.dueWithin3Days", "Due Within 3 Days")</p> + <p class="mt-1 text-3xl font-bold text-orange-700">@AppLocalization.Number(dueSoonCount)</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-orange-100"> + <i data-lucide="clock" class="h-6 w-6 text-orange-600"></i> + </span> + </div> + </div> + <div class="rounded-xl border border-violet-100 bg-violet-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-violet-500">@AppLocalization.Text("report.avgDaysOverdue", "Avg Days Overdue")</p> + <p class="mt-1 text-3xl font-bold text-violet-700">@AppLocalization.Number(avgDaysOverdue)</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-100"> + <i data-lucide="calendar-x" class="h-6 w-6 text-violet-600"></i> + </span> + </div> + </div> + </div> + } + + <!-- Filters --> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex-1 min-w-[200px]"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.searchTasks", "Search Tasks")</label> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchByTaskAssigneeProject", "Search by title, assignee, or project...")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.priority", "Priority")</label> + <select @bind="filterPriority" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allPriorities", "All Priorities")</option> + <option value="1">@AppLocalization.Text("priority.low", "Low")</option> + <option value="2">@AppLocalization.Text("priority.medium", "Medium")</option> + <option value="3">@AppLocalization.Text("priority.high", "High")</option> + </select> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.delayType", "Delay Type")</label> + <select @bind="filterDelayType" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="all">@AppLocalization.Text("report.allDelayed", "All Delayed")</option> + <option value="overdue">@AppLocalization.Text("report.overduePastDue", "Overdue (Past Due)")</option> + <option value="soon">@AppLocalization.Text("report.dueWithin3Days", "Due Within 3 Days")</option> + </select> + </div> + <button @onclick="ApplyFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetFilters" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-600 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4"></i> + </button> + + <div class="flex flex-wrap items-center gap-6 w-full border-t border-slate-100 pt-3 mt-1"> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMe" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMe", "Assigned to me")</span> + </label> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMyTeam" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMyTeam", "Assigned to my team")</span> + </label> + </div> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("report.loadingOverdueTasks", "Loading overdue tasks...")" /> + } + else + { + @if (isChartView) + { + <div class="grid grid-cols-1 gap-5 xl:grid-cols-12"> + <div class="xl:col-span-7"> + <ReportChartCard Title="@AppLocalization.Text("report.agingBuckets", "Aging buckets")" Subtitle="@AppLocalization.Text("report.agingBucketsDesc", "How long delayed work has been waiting.")" Insight="@AgingInsight"> + <AgingBucketChart Buckets="AgingBuckets" /> + </ReportChartCard> + </div> + <div class="xl:col-span-5"> + <RadialMetricCard Title="@AppLocalization.Text("report.highPriorityOverdue", "High-priority overdue")" Subtitle="@AppLocalization.Text("report.highPriorityOverdueDesc", "Critical work that needs immediate support.")" Percent="@HighPriorityOverduePercent" Value="@AppLocalization.Number(criticalCount)" ValueLabel="@AppLocalization.Text("priority.high", "high priority")" CenterLabel="@AppLocalization.Text("report.critical", "critical")" Color="#ef4444" Insight="@AppLocalization.Text("report.overdueInsight", "Phrase these as capacity risks so teams can unblock work, not assign blame.")" /> + </div> + <div class="xl:col-span-6"> + <ReportChartCard Title="@AppLocalization.Text("report.delaysByProject", "Delays by project")" Subtitle="@AppLocalization.Text("report.delaysByProjectDesc", "Project areas where delay concentration is highest.")" Insight="@AppLocalization.Text("report.delaysByProjectInsight", "Use this to rebalance planning pressure across projects.")"> + <TopRiskList Items="DelayProjectItems" EmptyText="@AppLocalization.Text("report.noProjectDelay", "No project delay concentration found.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-6"> + <ReportChartCard Title="@AppLocalization.Text("report.delaysByAssignee", "Delays by assignee")" Subtitle="@AppLocalization.Text("report.delaysByAssigneeDesc", "People who may need support or scope adjustment.")" Insight="@AppLocalization.Text("report.delaysByAssigneeInsight", "Support signal only: high counts should trigger help, not blame.")"> + <TopRiskList Items="DelayAssigneeItems" EmptyText="@AppLocalization.Text("report.noAssigneeDelay", "No assignee delay concentration found.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-12"> + <RiskMatrixCard Title="@AppLocalization.Text("report.severityVsDaysOverdue", "Severity vs days overdue")" Subtitle="@AppLocalization.Text("report.severityVsDaysOverdueDesc", "High priority and older delays sit in the upper-right risk zone.")" Points="OverdueRiskMatrix" Insight="@AppLocalization.Text("report.severityMatrixInsight", "The matrix combines priority severity with age of delay to show triage order.")" /> + </div> + </div> + } + else + { + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between"> + <h3 class="text-sm font-semibold text-slate-700"> + @AppLocalization.Text("report.showingDelayedTasks", "Showing") <span class="text-rose-600 font-bold">@AppLocalization.Number(overdueReportPage?.TotalCount ?? 0)</span> @AppLocalization.Text("report.delayedOrOverdueTasks", "delayed / overdue task(s)") + </h3> + </div> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.task", "Task")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.project", "Project")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.assignee", "Assignee")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.dueDate", "Due Date")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.daysOverdue", "Days Overdue")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.issue", "Issues")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.delayEscalation", "Delay / Escalation")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (overdueReportTasks.Any()) + { + @foreach (var entry in overdueReportTasks.Select((task, index) => (task, index))) + { + var task = entry.task; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500 align-top">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4"> + <div> + <p class="text-sm font-semibold text-slate-900">@Content.GetText(task.Title, task.TitleMy)</p> + @if (!string.IsNullOrEmpty(Content.GetText(task.Description, task.DescriptionMy))) + { + <p class="text-xs text-slate-400 mt-0.5 truncate max-w-[200px]">@Content.GetText(task.Description, task.DescriptionMy)</p> + } + </div> + </td> + <td class="px-6 py-4"> + <span class="inline-flex items-center gap-1 text-sm text-slate-600"> + <i data-lucide="folder" class="h-3.5 w-3.5 text-violet-500"></i> + @Content.GetText(task.ProjectName, task.ProjectNameMy) + </span> + </td> + <td class="px-6 py-4"> + @if (!string.IsNullOrEmpty(task.AssignedTo)) + { + <div class="flex items-center gap-2"> + <span class="flex h-7 w-7 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold"> + @(task.AssignedTo.Length > 0 ? task.AssignedTo[0].ToString().ToUpper() : "") + </span> + <span class="text-sm text-slate-700">@task.AssignedTo</span> + </div> + } + else + { + <span class="text-xs text-slate-400">@AppLocalization.Text("common.unassigned", "Unassigned")</span> + } + </td> + <td class="px-6 py-4"> + <span class="text-sm font-medium @(task.DaysOverdue > 0 ? "text-rose-600" : "text-amber-600")"> + @DisplayFormats.Date(task.DueDate) + </span> + </td> + <td class="px-6 py-4"> + @if (task.DaysOverdue > 0) + { + <span class="inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700"> + <i data-lucide="alert-triangle" class="h-3 w-3"></i> + @AppLocalization.Number(task.DaysOverdue) @AppLocalization.Text("report.days", "days") + </span> + } + else + { + var daysDue = Math.Abs(task.DaysOverdue); + <span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700"> + <i data-lucide="clock" class="h-3 w-3"></i> + @AppLocalization.Text("report.dueIn", "Due in") @AppLocalization.Number(daysDue) @AppLocalization.Text("report.days", "days") + </span> + } + </td> + <td class="px-6 py-4 text-sm text-slate-700"> + <span class="font-semibold text-rose-600">@AppLocalization.Number(task.OverdueIssues)</span> @AppLocalization.Text("common.overdue", "overdue") + </td> + <td class="px-6 py-4"> + <div class="text-sm font-medium text-slate-800">@GetDelayReasonLabel(task)</div> + <div class="mt-1 text-xs text-slate-500">@AppLocalization.Text("report.blockedBy", "Blocked by: ")@(string.IsNullOrWhiteSpace(Content.GetText(task.BlockedBy, task.BlockedByMy)) ? AppLocalization.Text("common.none", "None") : Content.GetText(task.BlockedBy, task.BlockedByMy)) · @AppLocalization.Text("report.escalation", "Escalation: ")@GetEscalationLabel(task.EscalationLevel)</div> + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="8" Icon="check-circle" Title="@AppLocalization.Text("report.noOverdueTasks", "No overdue tasks")" Subtitle="@AppLocalization.Text("report.noOverdueTasksDesc", "Great! No tasks are overdue or at risk based on the current filters.")" /> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="@(overdueReportPage?.TotalCount ?? 0)" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } + } +</div> + +@code { + private bool isLoading = true; + private bool isChartView = false; + private List<OverdueCriticalTaskDto> allOverdue = new(); + private List<OverdueCriticalTaskDto> overdueReportTasks = new(); + private PagedResult<OverdueCriticalTaskDto>? 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<ReportChartItem> AgingBuckets => new() + { + new(AppLocalization.Text("report.dueSoon", "Due soon"), allOverdue.Count(t => t.DaysOverdue <= 0), "#f59e0b", AppLocalization.Text("report.notOverdueYet", "not overdue yet")), + new(AppLocalization.Text("report.1to3Days", "1-3d"), allOverdue.Count(t => t.DaysOverdue >= 1 && t.DaysOverdue <= 3), "#3b82f6", AppLocalization.Text("report.earlyDelay", "early delay")), + new(AppLocalization.Text("report.4to7Days", "4-7d"), allOverdue.Count(t => t.DaysOverdue >= 4 && t.DaysOverdue <= 7), "#f59e0b", AppLocalization.Text("report.needsAction", "needs action")), + new(AppLocalization.Text("report.8PlusDays", "8d+"), allOverdue.Count(t => t.DaysOverdue >= 8), "#ef4444", AppLocalization.Text("report.highRisk", "high risk")) + }; + + private List<TopRiskItem> 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, $"{AppLocalization.Number(overdue)} {AppLocalization.Text("status.overdue", "overdue")} · {AppLocalization.Number(high)} {AppLocalization.Text("priority.high", "high priority")}", score, $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("report.tasks", "tasks")}", score >= 70 ? "rose" : "amber", $"{AppLocalization.Number(g.Sum(t => t.OverdueIssues))} {AppLocalization.Text("report.overdueIssuesSegment", "overdue issues")}"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List<TopRiskItem> DelayAssigneeItems => allOverdue + .GroupBy(t => string.IsNullOrWhiteSpace(t.AssignedTo) ? AppLocalization.Text("common.unassigned", "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, $"{AppLocalization.Number(overdue)} {AppLocalization.Text("report.overdueTasks", "overdue tasks")}", score, $"{AppLocalization.Number(score)} {AppLocalization.Text("report.risk", "risk")}", score >= 70 ? "rose" : "amber", AppLocalization.Text("report.capacitySupportNeeded", "Capacity support may be needed")); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List<RiskMatrixPoint> 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", $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("report.delayedTasksCount", "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<string>(); + 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<Result<List<OverdueCriticalTaskDto>>>(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; + } + + ApplyLocalPagination(); + } + catch (Exception ex) + { + Console.WriteLine($"Overdue report loading error: {ex.Message}"); + } + } + + private void ApplyLocalPagination() + { + var normalizedPageSize = Math.Clamp(pageSize <= 0 ? 10 : pageSize, 1, 100); + var totalCount = allOverdue.Count; + var totalPages = totalCount == 0 + ? 0 + : (int)Math.Ceiling(totalCount / (double)normalizedPageSize); + var normalizedPage = Math.Max(currentPage, 1); + + if (totalPages > 0 && normalizedPage > totalPages) + { + normalizedPage = totalPages; + } + + overdueReportTasks = totalPages == 0 + ? new List<OverdueCriticalTaskDto>() + : allOverdue + .Skip((normalizedPage - 1) * normalizedPageSize) + .Take(normalizedPageSize) + .ToList(); + + overdueReportPage = new PagedResult<OverdueCriticalTaskDto> + { + Items = overdueReportTasks, + TotalCount = totalCount, + Page = normalizedPage, + PageSize = normalizedPageSize, + TotalPages = totalPages + }; + + currentPage = normalizedPage; + pageSize = normalizedPageSize; + } + + 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<string>(); + 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<string> + { + $"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[] + { + Content.GetText(task.Title, task.TitleMy), + Content.GetText(task.ProjectName, task.ProjectNameMy), + string.IsNullOrWhiteSpace(Content.GetText(task.AssignedTo, task.AssignedToMy)) ? "Unassigned" : Content.GetText(task.AssignedTo, task.AssignedToMy), + 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 string GetDelayReasonLabel(OverdueCriticalTaskDto task) + { + var reason = Content.GetText(task.DelayReason, task.DelayReasonMy); + if (string.IsNullOrWhiteSpace(reason)) + { + return AppLocalization.Text("report.noReasonRecorded", "No reason recorded"); + } + + return reason.Trim() switch + { + "Past due date" => AppLocalization.Text("report.pastDueDate", "Past due date"), + "High priority due soon" => AppLocalization.Text("report.highPriorityDueSoon", "High priority due soon"), + _ => reason + }; + } + + 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..a45d0ccf2e3d123a0de50b99e74af4ba726093ba --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/ProjectProgressReport.razor @@ -0,0 +1,610 @@ +@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 + +<PageTitle>@AppLocalization.Text("report.projectProgressTitle", "Project Progress Report")</PageTitle> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("report.projectProgressHeading", "Project Progress")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("report.projectProgressDesc", "View project health using both planning tasks and daily execution issues.")</p> + </div> + <div class="flex items-center space-x-3"> + <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"> + <button @onclick="() => SetView(false)" class="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"> + <i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View") + </button> + <button @onclick="() => SetView(true)" class="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"> + <i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View") + </button> + </div> + <button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel") + </button> + <button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF") + </button> + </div> + </div> + + <!-- Filters --> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex-1 min-w-[200px]"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.searchProject", "Search Project")</label> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchByName", "Search by name...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.periodProjectStart", "Period (Project Start Date)")</label> + <select @bind="filterPeriod" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="all">@AppLocalization.Text("common.all", "All Time")</option> + <option value="daily">@AppLocalization.Text("report.daily", "Daily (Today)")</option> + <option value="monthly">@AppLocalization.Text("report.monthly", "Monthly (This Month)")</option> + <option value="yearly">@AppLocalization.Text("report.yearly", "Yearly (This Year)")</option> + <option value="custom">@AppLocalization.Text("report.customRange", "Custom Range")</option> + </select> + </div> + + @if (filterPeriod == "custom") + { + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date")</label> + <input @bind="filterStartDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.endDate", "End Date")</label> + <input @bind="filterEndDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + } + + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.taskStatusCompletion", "Task Status Completion")</label> + <select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allTasks", "All Tasks")</option> + <option value="1">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="2">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="3">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + <option value="overdue">@AppLocalization.Text("status.overdue", "Overdue")</option> + </select> + </div> + <button @onclick="ApplyAllFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetAllFilters" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + + <div class="flex flex-wrap items-center gap-6 w-full border-t border-slate-100 pt-3 mt-1"> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMe" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMe", "Assigned to me")</span> + </label> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMyTeam" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMyTeam", "Assigned to my team")</span> + </label> + </div> + </div> + </div> + + <!-- Content Area --> + @if (isLoading) + { + <div class="flex items-center justify-center py-20"> + <div class="flex items-center space-x-3 text-slate-500"> + <svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> + <span class="text-sm font-medium">@AppLocalization.Text("common.loading", "Loading data...")</span> + </div> + </div> + } + else + { + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> + <!-- Total Active Projects Card --> + <div class="rounded-xl border border-violet-100 bg-violet-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-violet-500 font-sans">@AppLocalization.Text("dashboard.activeProjects", "Total Active Projects")</p> + <p class="mt-1 text-3xl font-bold text-violet-700">@AppLocalization.Number(totalActiveProjects)</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-100"> + <i data-lucide="folder" class="h-6 w-6 text-violet-600"></i> + </span> + </div> + </div> + <!-- Ahead of Schedule Card --> + <div class="rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-emerald-500 font-sans">@AppLocalization.Text("report.aheadOfSchedule", "Ahead of Schedule")</p> + <p class="mt-1 text-3xl font-bold text-emerald-700">@AppLocalization.Number(aheadOfScheduleCount)</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100"> + <i data-lucide="trending-up" class="h-6 w-6 text-emerald-600"></i> + </span> + </div> + </div> + <!-- At Risk Card --> + <div class="rounded-xl border border-rose-100 bg-rose-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-rose-500 font-sans">@AppLocalization.Text("dashboard.atRisk", "At Risk")</p> + <p class="mt-1 text-3xl font-bold text-rose-700">@AppLocalization.Number(atRiskCount)</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100"> + <i data-lucide="alert-triangle" class="h-6 w-6 text-rose-600"></i> + </span> + </div> + </div> + </div> + + @if (isChartView) + { + <div class="grid grid-cols-1 gap-5 xl:grid-cols-12"> + <div class="xl:col-span-6"> + <RiskMatrixCard Title="@AppLocalization.Text("report.projectHealthMatrix", "Project health matrix")" Subtitle="@AppLocalization.Text("report.projectHealthMatrixDesc", "Progress health against issue risk.")" Points="ProjectHealthMatrix" Insight="@AppLocalization.Text("report.projectHealthMatrixInsight", "Projects in the upper-right combine low progress with active issue pressure.")" /> + </div> + <div class="xl:col-span-6"> + <ReportChartCard Title="@AppLocalization.Text("report.completionVsRisk", "Completion vs risk")" Subtitle="@AppLocalization.Text("report.completionVsRiskDesc", "Each project plotted by completion and issue pressure.")" Insight="@AppLocalization.Text("report.completionVsRiskInsight", "Lower completion with higher issue pressure should be reviewed first.")"> + @if (!ProjectQuadrantPoints.Any()) + { + <div class="rounded-lg border border-dashed border-slate-200 bg-slate-50 py-10 text-center text-sm text-slate-400">@AppLocalization.Text("report.noAtRiskProjects", "No project risk data available.")</div> + } + else + { + <div class="space-y-3"> + <div class="grid grid-cols-[44px_minmax(0,1fr)] gap-2"> + <div class="flex items-center justify-center rounded-lg bg-slate-50 text-center text-[11px] font-semibold uppercase tracking-wider text-slate-500 [writing-mode:vertical-rl] rotate-180"> + @AppLocalization.Text("common.risk", "Risk") + </div> + <div class="relative h-[280px] overflow-hidden rounded-xl border border-slate-200 bg-white"> + <div class="absolute inset-0 grid grid-cols-2 grid-rows-2"> + <div class="border-b border-r border-slate-200 bg-rose-50/50 p-3"> + <div class="text-xs font-bold text-rose-700">@AppLocalization.Text("report.reviewNow", "Review now")</div> + <div class="text-[11px] text-rose-600">@AppLocalization.Text("report.lowCompletionHighRisk", "Low completion · high risk")</div> + </div> + <div class="border-b border-slate-200 bg-amber-50/50 p-3"> + <div class="text-xs font-bold text-amber-700">@AppLocalization.Text("report.watchClosely", "Watch closely")</div> + <div class="text-[11px] text-amber-600">@AppLocalization.Text("report.highCompletionHighRisk", "High completion · high risk")</div> + </div> + <div class="border-r border-slate-200 bg-blue-50/50 p-3"> + <div class="text-xs font-bold text-blue-700">@AppLocalization.Text("report.recoverPlan", "Recover plan")</div> + <div class="text-[11px] text-blue-600">@AppLocalization.Text("report.lowCompletionLowRisk", "Low completion · low risk")</div> + </div> + <div class="bg-emerald-50/50 p-3"> + <div class="text-xs font-bold text-emerald-700">@AppLocalization.Text("report.onTrack", "On track")</div> + <div class="text-[11px] text-emerald-600">@AppLocalization.Text("report.highCompletionLowRisk", "High completion · low risk")</div> + </div> + </div> + + @foreach (var point in ProjectQuadrantPoints) + { + <div class="absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110" + title="@point.Tooltip" + style="left: @point.Left%; top: @point.Top%; width: 14px; height: 14px; background-color: @point.Color;"> + </div> + } + </div> + </div> + <div class="grid grid-cols-[44px_minmax(0,1fr)] gap-2"> + <div></div> + <div class="flex items-center justify-between text-[11px] font-semibold text-slate-400"> + <span>@AppLocalization.Text("report.percentComplete", "0% complete").Replace("{percent}", AppLocalization.Number(0))</span> + <span>@AppLocalization.Text("report.completion", "Completion")</span> + <span>@AppLocalization.Text("report.percentComplete", "100% complete").Replace("{percent}", AppLocalization.Number(100))</span> + </div> + </div> + <div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2 text-[11px] text-slate-500"> + <span class="font-semibold uppercase tracking-wider text-slate-400">@AppLocalization.Text("report.color", "Color")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-emerald-500"></span>@AppLocalization.Text("report.healthGood", "Healthy")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-blue-500"></span>@AppLocalization.Text("report.healthWatch", "Watch")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-amber-500"></span>@AppLocalization.Text("report.healthAtRisk", "At risk")</span> + <span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-rose-500"></span>@AppLocalization.Text("report.healthCritical", "Critical")</span> + </div> + </div> + } + </ReportChartCard> + </div> + <div class="xl:col-span-5"> + <ReportChartCard Title="@AppLocalization.Text("report.topAtRiskProjects", "Top at-risk projects")" Subtitle="@AppLocalization.Text("report.topAtRiskProjectsDesc", "Projects needing planning attention first.")" Insight="@AppLocalization.Text("report.topAtRiskProjectsInsight", "Risk blends overdue, blocked, open issues, and project health.")"> + <TopRiskList Items="AtRiskProjectItems" EmptyText="@AppLocalization.Text("report.noAtRiskProjects", "No at-risk projects in the current filter.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-7"> + <ReportChartCard Title="@AppLocalization.Text("report.timelineSummary", "Timeline summary")" Subtitle="@AppLocalization.Text("report.timelineSummaryDesc", "Project schedule window with completion overlay.")" Insight="@AppLocalization.Text("report.timelineSummaryInsight", "This gives the planning horizon without leaving the report.")"> + <TimelineChart Items="ProjectTimelineItems" WrapperClass="max-h-[340px] overflow-auto" /> + </ReportChartCard> + </div> + <div class="xl:col-span-12"> + <ReportChartCard Title="@AppLocalization.Text("report.issuesCompositionByProject", "Open, overdue, and blocking issues by project")" Subtitle="@AppLocalization.Text("report.issuesCompositionByProjectDesc", "Issue composition across the project portfolio.")" Insight="@AppLocalization.Text("report.issuesCompositionByProjectInsight", "Open work is normal; overdue and blocked work are the triage signal.")"> + <StackedProgressBar Segments="ProjectIssueSegments" AriaLabel="Project issue summary" /> + </ReportChartCard> + </div> + </div> + } + else + { + <!-- List View --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("project.projectName", "Project Name")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.startDate", "Start Date")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.endDate", "End Date")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.openIssues", "Open Issues")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.blockedOverdue", "Blocked / Overdue")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.health", "Health")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.lastActivity", "Last Activity")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (pagedProjects.Any()) + { + @foreach (var entry in pagedProjects.Select((proj, index) => (proj, index))) + { + var proj = entry.proj; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4 text-sm font-semibold text-slate-900">@Content.GetText(proj.ProjectName, proj.ProjectNameMy)</td> + <td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.StartDate)</td> + <td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.EndDate)</td> + <td class="px-6 py-4 text-sm text-slate-900">@AppLocalization.Number(proj.OpenIssues)</td> + <td class="px-6 py-4 text-sm font-medium"><span class="text-amber-600">@AppLocalization.Number(proj.BlockedIssues)</span> / <span class="text-rose-600">@AppLocalization.Number(proj.OverdueIssues)</span></td> + <td class="px-6 py-4"><span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold @GetProjectHealthBadge(proj.ProjectHealth)">@GetProjectHealthLabel(proj.ProjectHealth)</span></td> + <td class="px-6 py-4 text-sm text-slate-600">@DisplayFormats.Date(proj.LastActivity)</td> + </tr> + } + } + else + { + <tr> + <td colspan="8" class="px-6 py-12 text-center"> + <p class="text-sm text-slate-400">@AppLocalization.Text("report.noProjectData", "No project data found matching criteria.")</p> + </td> + </tr> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="TotalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } + } +</div> + +@code { + private bool isLoading = true; + private bool isChartView = false; + private List<ProjectProgressReportDto> allProjects = new(); + private List<ProjectProgressReportDto> pagedProjects = new(); + private PagedResult<ProjectProgressReportDto>? 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<RiskMatrixPoint> ProjectHealthMatrix => allProjects + .Select(project => new RiskMatrixPoint( + project.ProjectName, + GetProjectSeverity(project), + GetProjectUrgency(project), + 1, + project.ProjectHealth == "Critical" ? "#ef4444" : project.IsAtRisk ? "#f59e0b" : "#3b82f6", + $"{AppLocalization.Text("report.percentComplete", "{percent}% complete").Replace("{percent}", AppLocalization.Number(project.Progress, "0"))}, {AppLocalization.Number(project.BlockedIssues)} {AppLocalization.Text("common.blocked", "blocked")}, {AppLocalization.Number(project.OverdueIssues)} {AppLocalization.Text("common.overdue", "overdue")}")) + .ToList(); + + private List<ProjectQuadrantPoint> 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 projectName = Content.GetText(project.ProjectName, project.ProjectNameMy); + var tooltip = $"{projectName}\n{AppLocalization.Text("report.percentComplete", "{percent}% complete").Replace("{percent}", AppLocalization.Number(project.Progress, "0"))}\n{AppLocalization.Text("report.riskScore", "Risk score")}: {AppLocalization.Number(risk, "0")}\n{AppLocalization.Number(project.BlockedIssues)} {AppLocalization.Text("common.blocked", "blocked")}, {AppLocalization.Number(project.OverdueIssues)} {AppLocalization.Text("common.overdue", "overdue")}, {AppLocalization.Number(project.OpenIssues)} {AppLocalization.Text("report.open", "open")}"; + return new ProjectQuadrantPoint(projectName, left, top, color, tooltip); + }) + .ToList(); + + private List<TopRiskItem> AtRiskProjectItems => allProjects + .Select(project => + { + var score = GetProjectRiskScore(project); + var tone = project.ProjectHealth == "Critical" || project.BlockedIssues > 0 ? "rose" : project.IsAtRisk ? "amber" : "blue"; + return new TopRiskItem(Content.GetText(project.ProjectName, project.ProjectNameMy), $"{AppLocalization.Text("report.percentComplete", "{percent}% complete").Replace("{percent}", AppLocalization.Number(project.Progress, "0"))} · {AppLocalization.Number(project.OpenIssues)} {AppLocalization.Text("report.openIssuesSegment", "open issues")}", score, GetProjectHealthLabel(project.ProjectHealth), tone, $"{AppLocalization.Number(project.OverdueIssues)} {AppLocalization.Text("status.overdue", "overdue")} · {AppLocalization.Number(project.BlockedIssues)} {AppLocalization.Text("status.blocked", "blocked")}"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List<TimelineChartItem> ProjectTimelineItems => allProjects + .OrderBy(project => project.StartDate) + .Take(8) + .Select(project => new TimelineChartItem(Content.GetText(project.ProjectName, project.ProjectNameMy), project.StartDate, project.EndDate, project.Progress, project.IsAtRisk ? "#f59e0b" : "#3b82f6")) + .ToList(); + + private List<StackedSegment> ProjectIssueSegments => new() + { + new(AppLocalization.Text("report.openIssuesSegment", "Open issues"), allProjects.Sum(project => project.OpenIssues), "#3b82f6"), + new(AppLocalization.Text("report.overdueIssuesSegment", "Overdue issues"), allProjects.Sum(project => project.OverdueIssues), "#ef4444"), + new(AppLocalization.Text("report.blockedIssuesSegment", "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<string>(); + 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<Result<List<ProjectProgressReportDto>>>(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<string>(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<PagedResult<ProjectProgressReportDto>>(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?[] + { + Content.GetText(proj.ProjectName, proj.ProjectNameMy), + 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<string> + { + $"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[] + { + Content.GetText(proj.ProjectName, proj.ProjectNameMy), + 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 string GetProjectHealthLabel(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 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..073463329cc9c0a4a220d78f83433bc894c56adf --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TaskReport.razor @@ -0,0 +1,802 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("taskReport", "Task Report")</PageTitle> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("taskReport", "Task Report")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.PageDescription("taskReport", "Analyze task activity and export task reports.")</p> + </div> + <div class="flex items-center space-x-3"> + <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"> + <button @onclick="() => SetView(false)" class="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"> + <i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View") + </button> + <button @onclick="() => SetView(true)" class="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"> + <i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View") + </button> + </div> + <button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel") + </button> + <button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF") + </button> + </div> + </div> + + <!-- Filters Section --> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex-1 min-w-[200px]"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.searchTasks", "Search Task")</label> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchByTitle", "Search by title or project...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("report.periodFilter", "Period Filter")</label> + <select @bind="filterPeriod" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="all">@AppLocalization.Text("common.all", "All Time")</option> + <option value="daily">@AppLocalization.Text("report.daily", "Daily (Today)")</option> + <option value="monthly">@AppLocalization.Text("report.monthly", "Monthly (This Month)")</option> + <option value="yearly">@AppLocalization.Text("report.yearly", "Yearly (This Year)")</option> + <option value="custom">@AppLocalization.Text("report.customRange", "Custom Range")</option> + </select> + </div> + + @if (filterPeriod == "custom") + { + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.startDate", "Start Date")</label> + <input @bind="filterStartDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.endDate", "End Date")</label> + <input @bind="filterEndDate" type="date" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + } + + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.status", "Status")</label> + <select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="uncompleted">@AppLocalization.Text("status.uncompleted", "Uncompleted")</option> + <option value="">@AppLocalization.Text("common.all", "All")</option> + <option value="1">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="2">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="3">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + <option value="overdue">@AppLocalization.Text("status.overdue", "Overdue")</option> + </select> + </div> + <div class="relative min-w-[240px]"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.project", "Project")</label> + <div class="relative"> + <input @bind="projectSearchInput" @bind:event="oninput" + @onfocus="ShowProjectSuggestions" + @onblur="HideProjectSuggestionsDelayed" + type="text" + placeholder="@AppLocalization.Text("common.searchProjects", "Search projects...")" + autocomplete="off" + class="w-full rounded-lg border border-slate-200 px-3 py-2 pr-8 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + @if (filterProjectId > 0 || !string.IsNullOrWhiteSpace(projectSearchInput)) + { + <button type="button" + @onmousedown="ClearProjectFilter" + class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors" + title="@AppLocalization.Text("common.clear", "Clear project filter")"> + <i data-lucide="x" class="h-4 w-4"></i> + </button> + } + </div> + @if (showProjectSuggestions && FilteredProjects.Any()) + { + <ul class="absolute z-20 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg"> + <li> + <button type="button" + @onmousedown="ClearProjectFilter" + class="w-full px-3 py-2 text-left text-sm text-slate-600 hover:bg-slate-50 transition-colors"> + @AppLocalization.Text("common.all", "All Projects") + </button> + </li> + @foreach (var p in FilteredProjects.Take(10)) + { + <li> + <button type="button" + @onmousedown="() => SelectProject(p)" + class="w-full px-3 py-2 text-left text-sm text-slate-900 hover:bg-slate-50 transition-colors @(filterProjectId == p.Id ? "bg-slate-50 font-medium" : "")"> + @Content.GetText(p.Name, p.NameMy) + </button> + </li> + } + </ul> + } + </div> + <button @onclick="ApplyAllFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetAllFilters" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + + <div class="flex flex-wrap items-center gap-6 w-full border-t border-slate-100 pt-3 mt-1"> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMe" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMe", "Assigned to me")</span> + </label> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMyTeam" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMyTeam", "Assigned to my team")</span> + </label> + </div> + </div> + </div> + + @if (isLoading) + { + <div class="flex items-center justify-center py-20"> + <div class="flex items-center space-x-3 text-slate-500"> + <svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> + <span class="text-sm font-medium">@AppLocalization.Text("common.loading", "Loading data...")</span> + </div> + </div> + } + else if (!string.IsNullOrWhiteSpace(errorMessage)) + { + <div class="rounded-xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-700 shadow-sm"> + <div class="flex items-start gap-3"> + <i data-lucide="alert-triangle" class="mt-0.5 h-5 w-5 shrink-0"></i> + <div> + <p class="font-semibold">@AppLocalization.Text("report.couldNotLoad", "Task report could not load.")</p> + <p class="mt-1">@errorMessage</p> + </div> + </div> + </div> + } + else + { + <div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4"> + @TaskMetricCard(AppLocalization.Text("report.total", "Total"), AppLocalization.Number(FilteredList.Count()), "file-text", "violet") + @TaskMetricCard(AppLocalization.Text("status.done", "Completed"), AppLocalization.Number(FilteredList.Count(t => t.StatusId == AppTaskStatus.Done)), "check-circle-2", "emerald") + @TaskMetricCard(AppLocalization.StatusLabel(AppTaskStatus.InProgress), AppLocalization.Number(FilteredList.Count(t => t.StatusId == AppTaskStatus.InProgress)), "timer", "blue") + @TaskMetricCard(AppLocalization.Text("status.overdue", "Overdue"), AppLocalization.Number(FilteredList.Count(t => t.DueDate.Date < DateTime.Today && t.StatusId != AppTaskStatus.Done)), "alarm-clock", "rose") + </div> + + <InsightPanel Insights="TaskInsights.ToList()" /> + + @if (isChartView) + { + <div class="grid grid-cols-1 gap-5 xl:grid-cols-12"> + <div class="xl:col-span-5"> + <ReportChartCard Title="@AppLocalization.Text("report.taskStatusMix", "Task status mix")" Subtitle="@AppLocalization.Text("report.taskStatusMixDesc", "Where execution currently sits across the task lifecycle.")" Insight="@TaskStatusInsight"> + <DonutStatusChart Items="TaskStatusItems" CenterLabel="@AppLocalization.Text("common.task", "tasks")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-4"> + <ReportChartCard Title="@AppLocalization.Text("report.priorityDistribution", "Priority distribution")" Subtitle="@AppLocalization.Text("report.priorityDistributionDesc", "Planning pressure by task priority.")" Value="@AppLocalization.Number(HighPriorityCount)" ValueLabel="@AppLocalization.Text("priority.high", "high priority")" Insight="@PriorityInsight"> + <StackedProgressBar Segments="PrioritySegments" AriaLabel="Task priority distribution" /> + </ReportChartCard> + </div> + <div class="xl:col-span-3"> + <TrendSparklineCard Title="@AppLocalization.Text("report.completionMovement", "Completion movement")" Subtitle="@AppLocalization.Text("report.completionMovementDesc", "Recently completed tasks by creation month.")" Value="@($"{AppLocalization.Number(CompletionRate, "0")}%")" ValueLabel="@AppLocalization.Text("report.completion", "completion")" Points="CompletionSparkline" Color="#10b981" Insight="@CompletionInsight" /> + </div> + <div class="xl:col-span-6"> + <ReportChartCard Title="@AppLocalization.Text("report.topProjectRisk", "Top project risk")" Subtitle="@AppLocalization.Text("report.topProjectRiskDesc", "Projects with the highest task and issue pressure.")" Insight="@AppLocalization.Text("report.topProjectRiskInsight", "Score combines task volume, open issues, overdue issues, and critical task health.")"> + <TopRiskList Items="ProjectRiskItems" EmptyText="@AppLocalization.Text("common.noResults", "No project risk in this filtered set.")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-6"> + <ReportChartCard Title="@AppLocalization.Text("report.tasksWithMostIssues", "Tasks with most open issues")" Subtitle="@AppLocalization.Text("report.tasksWithMostIssuesDesc", "Execution blockers that may need issue cleanup before progress moves.")" Insight="@AppLocalization.Text("report.tasksWithMostIssuesInsight", "Use this to decide where issue triage creates the fastest task movement.")"> + <TopRiskList Items="TaskIssueRiskItems" EmptyText="@AppLocalization.Text("common.noResults", "No open issue pressure in this filtered set.")" /> + </ReportChartCard> + </div> + </div> + } + else + { + <!-- List View --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.task", "Task")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.project", "Project")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.status", "Status")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.priority", "Priority")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("report.health", "Health")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.issue", "Issues")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (PagedList.Any()) + { + @foreach (var entry in PagedList.Select((t, index) => (t, index))) + { + var t = entry.t; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-3 text-sm font-semibold text-slate-500">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-3"> + <button @onclick="() => ToggleExpanded(t.TaskId)" class="text-left text-sm font-medium text-slate-900 hover:text-violet-600">@Content.GetText(t.Title, t.TitleMy)</button> + </td> + <td class="px-6 py-3 text-sm text-slate-500">@Content.GetText(t.ProjectName, t.ProjectNameMy)</td> + <td class="px-6 py-3"> + <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetStatusBadge(t.StatusId)">@AppLocalization.StatusLabel(t.StatusId)</span> + </td> + <td class="px-6 py-3"> + <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetPriorityBadge(t.PriorityId)">@AppLocalization.PriorityLabel(t.PriorityId)</span> + </td> + <td class="px-6 py-3"> + <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold @GetHealthBadge(t.TaskHealth)">@GetHealthLabel(t.TaskHealth)</span> + </td> + <td class="px-6 py-3 text-sm text-slate-600 font-sans"> + @if (t.OpenIssues == 0 && t.OverdueIssues == 0 && t.BlockedIssues == 0) + { + @AppLocalization.Text("report.noIssues", "No issues") + } + else + { + @($"{AppLocalization.Number(t.OpenIssues)} {AppLocalization.Text("report.open", "open")}, {AppLocalization.Number(t.OverdueIssues)} {AppLocalization.Text("status.overdue", "overdue")}, {AppLocalization.Number(t.BlockedIssues)} {AppLocalization.Text("status.blocked", "blocked")}") + } + </td> + </tr> + @if (expandedTasks.Contains(t.TaskId)) + { + <tr class="bg-slate-50/60"> + <td colspan="7" class="px-6 py-4"> + <div class="grid grid-cols-1 gap-3 text-sm text-slate-600 md:grid-cols-4"> + <div><span class="font-semibold text-slate-800">@AppLocalization.Text("common.assignee", "Assignee"):</span> @(string.IsNullOrWhiteSpace(Content.GetText(t.AssignedToUser, t.AssignedToUserMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(t.AssignedToUser, t.AssignedToUserMy))</div> + <div>@DisplayFormats.Date(t.DueDate)</div> + <div><span class="font-semibold text-slate-800">@AppLocalization.Text("report.openIssues", "Open issues"):</span> @t.OpenIssues</div> + <div><span class="font-semibold text-slate-800">@AppLocalization.Text("report.lastActivity", "Last activity"):</span> @DisplayFormats.Date(t.LastActivity)</div> + </div> + </td> + </tr> + } + } + } + else + { + <tr> + <td colspan="7" class="px-6 py-16 text-center bg-slate-50/20"> + <div class="flex flex-col items-center justify-center space-y-4"> + <svg class="w-24 h-24 text-slate-300" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect x="25" y="15" width="70" height="90" rx="8" stroke="currentColor" stroke-width="3" stroke-dasharray="6 4" /> + <path d="M45 40H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" /> + <path d="M45 55H75" stroke="currentColor" stroke-width="3" stroke-linecap="round" /> + <path d="M45 70H60" stroke="currentColor" stroke-width="3" stroke-linecap="round" /> + <circle cx="85" cy="85" r="15" fill="white" stroke="#111827" stroke-width="3" /> + <path d="M80 85L90 85M85 80L85 90" stroke="#111827" stroke-width="3" stroke-linecap="round" /> + </svg> + <h3 class="text-lg font-semibold text-slate-700">@AppLocalization.Text("report.noTasksFound", "No Tasks Found")</h3> + <p class="text-sm text-slate-400 max-w-xs">@AppLocalization.Text("report.noTasksFoundDesc", "There are no tasks matching your filters. Try adjusting your search query or filters.")</p> + </div> + </td> + </tr> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="taskReportPage?.TotalCount ?? 0" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } + } +</div> + +@code { + private bool isLoading = true; + private bool isChartView = false; + private List<TaskReportDto> taskReports = new(); + private HashSet<long> expandedTasks = new(); + private PagedResult<TaskReportDto>? taskReportPage; + private List<ProjectDto> 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 => Content.GetProjectName(p).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<TaskReportDto> FilteredList => taskReports; + + private int TotalPages => taskReportPage?.TotalPages ?? 0; + + private IEnumerable<TaskReportDto> PagedList => taskReportPage?.Items ?? Enumerable.Empty<TaskReportDto>(); + + private IEnumerable<string> TaskInsights => new[] + { + $"{AppLocalization.Number(FilteredList.Count(t => t.TaskHealth == "Critical"))} {AppLocalization.Text("report.criticalTasksNeedAttention", "critical tasks need attention.")}", + $"{AppLocalization.Number(FilteredList.Sum(t => t.OpenIssues))} {AppLocalization.Text("report.openIssuesAttached", "open issues are attached to reported tasks.")}", + $"{AppLocalization.Number(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 + ? $"{AppLocalization.Number(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 + ? $"{AppLocalization.Number(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<ReportChartItem> 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<StackedSegment> 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<SparklinePoint> CompletionSparkline => FilteredList + .GroupBy(t => new DateTime(t.CreatedAt.Year, t.CreatedAt.Month, 1)) + .OrderBy(g => g.Key) + .TakeLast(8) + .Select(g => new SparklinePoint(AppLocalization.MonthName(g.Key, abbreviated: true), g.Count(t => t.StatusId == AppTaskStatus.Done))) + .DefaultIfEmpty(new SparklinePoint(AppLocalization.MonthName(DateTime.Today, abbreviated: true), CompletedTaskCount)) + .ToList(); + + private List<TopRiskItem> ProjectRiskItems => FilteredList + .GroupBy(t => Content.GetText(t.ProjectName, t.ProjectNameMy)) + .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, + $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("common.task", "tasks")} · {AppLocalization.Number(g.Sum(t => t.OpenIssues))} {AppLocalization.Text("common.issue", "open issues")}", + score, + overdue > 0 ? $"{AppLocalization.Number(overdue)} {AppLocalization.Text("status.overdue", "overdue")}" : $"{AppLocalization.Number((double)score, "0")} {AppLocalization.Text("report.risk", "risk")}", + tone, + $"{AppLocalization.Number(g.Count(t => t.PriorityId == TaskPriority.High))} {AppLocalization.Text("report.highPriorityTasks", "high-priority tasks")}"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private List<TopRiskItem> 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( + Content.GetText(t.Title, t.TitleMy), + Content.GetText(t.ProjectName, t.ProjectNameMy), + score, + $"{AppLocalization.Number(t.OpenIssues + t.OverdueIssues)} {AppLocalization.Text("common.issue", "issues")}", + tone, + $"{AppLocalization.StatusLabel(t.StatusId)} · {AppLocalization.PriorityLabel(t.PriorityId)}"); + }) + .ToList(); + + private List<ProgressListChartItem> TasksByProjectItems => FilteredList + .GroupBy(t => Content.GetText(t.ProjectName, t.ProjectNameMy)) + .OrderByDescending(g => g.Count()) + .Take(5) + .Select(g => new ProgressListChartItem(g.Key, g.Count(), AppLocalization.Number(g.Count()), "#7c3aed")) + .ToList(); + + private double TasksByProjectMax => Math.Max(1, TasksByProjectItems.Select(item => item.Value).DefaultIfEmpty(0).Max()); + + private List<ProgressListChartItem> TasksWithMostIssuesItems => FilteredList + .OrderByDescending(t => t.OpenIssues + t.OverdueIssues) + .Take(5) + .Select(t => new ProgressListChartItem(Content.GetText(t.Title, t.TitleMy), t.OpenIssues + t.OverdueIssues, AppLocalization.Number(t.OpenIssues + t.OverdueIssues), "#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<List<ProjectDto>>("Project") ?? new(); + } + catch { } + + await LoadTaskReport(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await JS.InvokeVoidAsync("initIcons"); + } + + private IEnumerable<ProjectDto> FilteredProjects => + string.IsNullOrWhiteSpace(projectSearchQuery) + ? projects + : projects.Where(p => + p.Name.Contains(projectSearchQuery.Trim(), StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(p.NameMy) && p.NameMy.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 = Content.GetProjectName(project); + projectSearchQuery = Content.GetProjectName(project); + 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; + taskReports = new(); + taskReportPage = null; + projectSearchQuery = projectSearchInput; + StateHasChanged(); + + var client = ApiClient.CreateClient(); + try + { + errorMessage = null; + var query = BuildTaskReportQuery(); + var fullTask = client.GetFromJsonAsync<Result<List<TaskReportDto>>>(query); + var pagedTask = client.GetFromJsonAsync<PagedResult<TaskReportDto>>(BuildTaskReportQuery(includePaging: true)); + await System.Threading.Tasks.Task.WhenAll(fullTask, pagedTask); + + if (fullTask.Result?.IsSuccess == true && fullTask.Result.Value != null) + { + taskReports = fullTask.Result.Value + .Where(task => MatchesTaskSearch(task, searchQuery)) + .ToList(); + } + 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) + { + if (string.IsNullOrWhiteSpace(searchQuery)) + { + taskReportPage = pagedTask.Result; + currentPage = taskReportPage.Page; + pageSize = taskReportPage.PageSize; + } + else + { + var totalCount = taskReports.Count; + var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); + currentPage = totalPages > 0 ? Math.Min(currentPage, totalPages) : 1; + var filteredPage = taskReports + .OrderBy(task => task.DueDate) + .ThenByDescending(task => task.CreatedAt) + .Skip((currentPage - 1) * pageSize) + .Take(pageSize) + .ToList(); + taskReportPage = new PagedResult<TaskReportDto> + { + Items = filteredPage, + TotalCount = totalCount, + Page = currentPage, + PageSize = pageSize, + TotalPages = totalPages + }; + } + } + } + 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<string>(); + if (!string.IsNullOrWhiteSpace(searchQuery)) parts.Add($"search={Uri.EscapeDataString(searchQuery.Trim())}"); + 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 static bool MatchesTaskSearch(TaskReportDto task, string? search) + { + if (string.IsNullOrWhiteSpace(search)) + { + return true; + } + + var term = search.Trim(); + return ContainsSearch(task.Title, term) || + ContainsSearch(task.TitleMy, term) || + ContainsSearch(task.Description, term) || + ContainsSearch(task.DescriptionMy, term) || + ContainsSearch(task.ProjectName, term) || + ContainsSearch(task.ProjectNameMy, term) || + ContainsSearch(task.AssignedToUser, term) || + ContainsSearch(task.AssignedToUserMy, term) || + ContainsSearch(task.AssignedByUser, term) || + ContainsSearch(task.AssignedByUserMy, term); + } + + private static bool ContainsSearch(string? value, string search) + => !string.IsNullOrWhiteSpace(value) && value.Contains(search, StringComparison.OrdinalIgnoreCase); + + private async Task DownloadExcel() + { + var rows = FilteredList.Select(t => new object?[] + { + Content.GetText(t.Title, t.TitleMy), + Content.GetText(t.ProjectName, t.ProjectNameMy), + AppLocalization.StatusLabel(t.StatusId), + AppLocalization.PriorityLabel(t.PriorityId), + string.IsNullOrWhiteSpace(Content.GetText(t.AssignedToUser, t.AssignedToUserMy)) ? AppLocalization.Text("common.na", "Unassigned") : Content.GetText(t.AssignedToUser, t.AssignedToUserMy), + 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) is { } project ? Content.GetProjectName(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<string> + { + $"{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) is { } project ? Content.GetProjectName(project) : AppLocalization.Text("common.all", "All projects"))}", + $"{AppLocalization.Text("common.date", "Generated")}: {DisplayFormats.Date(DateTime.Today)}" + }; + + var rows = FilteredList.Select(t => new[] + { + Content.GetText(t.Title, t.TitleMy), + Content.GetText(t.ProjectName, t.ProjectNameMy), + AppLocalization.StatusLabel(t.StatusId), + AppLocalization.PriorityLabel(t.PriorityId), + string.IsNullOrWhiteSpace(Content.GetText(t.AssignedToUser, t.AssignedToUserMy)) ? AppLocalization.Text("common.na", "Unassigned") : Content.GetText(t.AssignedToUser, t.AssignedToUserMy), + 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, $"<div class=\"flex items-center justify-between\"><div><p class=\"text-xs font-semibold uppercase tracking-wider text-{color}-600\">{label}</p><p class=\"mt-1 text-3xl font-bold text-{color}-700\">{value}</p></div><span class=\"flex h-11 w-11 items-center justify-center rounded-xl bg-white/70\"><i data-lucide=\"{icon}\" class=\"h-6 w-6 text-{color}-600\"></i></span></div>"); + 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..b22b1b0876f6f1a8fa7395026d4373674762a641 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Reports/TimeTrackingReport.razor @@ -0,0 +1,655 @@ +@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 + +<PageTitle>@AppLocalization.Text("report.timeTrackingTitle", "Time & Effort Tracking Report")</PageTitle> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("report.timeTrackingHeading", "Time & Effort Tracking")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("report.timeTrackingDesc", "Review estimated hours, completion timelines, and resource effort distribution across issues.")</p> + </div> + <div class="flex items-center space-x-3"> + <div class="inline-flex rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"> + <button @onclick="() => SetView(false)" class="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"> + <i data-lucide="list" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.listView", "List View") + </button> + <button @onclick="() => SetView(true)" class="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"> + <i data-lucide="bar-chart-3" class="h-3.5 w-3.5 mr-1"></i> @AppLocalization.Text("common.chartView", "Chart View") + </button> + </div> + <button @onclick="DownloadExcel" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="download" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportExcel", "Export Excel") + </button> + <button @onclick="DownloadPdf" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="file-text" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("common.exportPdf", "Export PDF") + </button> + </div> + </div> + + <!-- Stats summary --> + @if (!isLoading) + { + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> + <div class="rounded-xl border border-violet-100 bg-violet-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-violet-500">@AppLocalization.Text("report.totalEstimatedEffort", "Total Estimated Effort")</p> + <p class="mt-1 text-3xl font-bold text-violet-700">@AppLocalization.Number(totalHours, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs")</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-100"> + <i data-lucide="clock" class="h-6 w-6 text-violet-600"></i> + </span> + </div> + </div> + <div class="rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-emerald-500">@AppLocalization.Text("common.actualHours", "Actual Hours")</p> + <p class="mt-1 text-3xl font-bold text-emerald-700">@AppLocalization.Number(actualHours, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs")</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100"> + <i data-lucide="check-circle-2" class="h-6 w-6 text-emerald-600"></i> + </span> + </div> + </div> + <div class="rounded-xl border border-amber-100 bg-amber-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-amber-500">@AppLocalization.Text("report.variance", "Variance")</p> + <p class="mt-1 text-3xl font-bold text-amber-700">@AppLocalization.LocalizeDigits(varianceHours.ToString("+0.0;-0.0;0.0", System.Globalization.CultureInfo.InvariantCulture)) @AppLocalization.Text("common.hoursAbbrev", "hrs")</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100"> + <i data-lucide="timer" class="h-6 w-6 text-amber-600"></i> + </span> + </div> + </div> + <div class="rounded-xl border border-rose-100 bg-rose-50 p-5 shadow-sm"> + <div class="flex items-center justify-between"> + <div> + <p class="text-xs font-semibold uppercase tracking-wider text-rose-500">@AppLocalization.Text("report.utilization", "Utilization")</p> + <p class="mt-1 text-3xl font-bold text-rose-700">@AppLocalization.Number(utilizationPercent, "N0")%</p> + </div> + <span class="flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100"> + <i data-lucide="alert-triangle" class="h-6 w-6 text-rose-600"></i> + </span> + </div> + </div> + </div> + } + + <!-- Filters --> + <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex flex-col gap-4"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex-1 min-w-[200px]"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.searchByIssueAssignee", "Search Issues / Assignee")</label> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchIssues", "Search issue title, description or assignee...")" + class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.project", "Project")</label> + <select @bind="filterProjectId" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allProjects", "All Projects")</option> + @foreach (var proj in allProjects) + { + <option value="@proj.Id">@Content.GetProjectName(proj)</option> + } + </select> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.status", "Issue Status")</label> + <select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.all", "All Statuses")</option> + <option value="Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + </select> + </div> + <button @onclick="ApplyFilters" class="inline-flex items-center rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetFilters" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 text-slate-600 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4"></i> + </button> + </div> + + <div class="flex flex-wrap items-center gap-6 border-t border-slate-100 pt-3"> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMe" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMe", "Assigned to me")</span> + </label> + <label class="flex items-center space-x-2 text-sm font-medium text-slate-700 cursor-pointer select-none"> + <input type="checkbox" @bind="filterAssignToMyTeam" class="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" /> + <span>@AppLocalization.Text("report.assignedToMyTeam", "Assigned to my team")</span> + </label> + </div> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("report.loadingTimesheet", "Loading timesheet data...")" /> + } + else + { + @if (isChartView) + { + <div class="grid grid-cols-1 gap-5 xl:grid-cols-12 font-sans"> + <div class="xl:col-span-5"> + <MetricComparisonCard Title="@AppLocalization.Text("report.estVsActualEffort", "Estimated vs actual effort")" + Subtitle="@AppLocalization.Text("report.estVsActualEffortDesc", "Are plans matching delivery effort?")" + PrimaryLabel="@AppLocalization.Text("report.actual", "Actual")" + PrimaryValue="@($"{AppLocalization.Number(actualHours)} {AppLocalization.Text("common.hoursAbbrev", "hrs")}")" + SecondaryLabel="@AppLocalization.Text("report.estimated", "Estimated")" + SecondaryValue="@($"{AppLocalization.Number(totalHours)} {AppLocalization.Text("common.hoursAbbrev", "hrs")}")" + Segments="EffortComparisonSegments" + Insight="@EffortVarianceInsight" /> + </div> + <div class="xl:col-span-3"> + <RadialMetricCard Title="@AppLocalization.Text("report.utilizationGauge", "Utilization gauge")" + Subtitle="@AppLocalization.Text("report.utilizationGaugeDesc", "Actual hours as a share of estimated effort.")" + Percent="@utilizationPercent" + Value="@($"{AppLocalization.Number(varianceHours)} {AppLocalization.Text("common.hoursAbbrev", "hrs")}")" + ValueLabel="@AppLocalization.Text("report.varianceLabel", "variance")" + CenterLabel="@AppLocalization.Text("report.usedLabel", "used")" + Color="@UtilizationColor" + Insight="@UtilizationInsight" /> + </div> + <div class="xl:col-span-4"> + <ReportChartCard Title="@AppLocalization.Text("report.estimateAccuracy", "Estimate accuracy")" Subtitle="@AppLocalization.Text("report.estimateAccuracyDesc", "How many items stayed within estimate?")" Value="@AppLocalization.Number(OnOrUnderEstimateCount)" ValueLabel="@AppLocalization.Text("report.onOrUnder", "on / under")" Insight="@EstimateAccuracyInsight"> + <DonutStatusChart Items="EstimateAccuracyDonutItems" CenterLabel="@AppLocalization.Text("report.issuesLabel", "issues")" /> + </ReportChartCard> + </div> + <div class="xl:col-span-7"> + <ReportChartCard Title="@AppLocalization.Text("report.effortByProject", "Effort by project")" Subtitle="@AppLocalization.Text("report.effortByProjectDesc", "Actual hours distributed across active projects.")" Insight="@AppLocalization.Text("report.effortByProjectInsight", "Use this to spot projects consuming more delivery capacity than expected.")"> + <StackedProgressBar Segments="ProjectEffortSegments" AriaLabel="Effort by project" /> + </ReportChartCard> + </div> + <div class="xl:col-span-5"> + <ReportChartCard Title="@AppLocalization.Text("report.effortLeaderboard", "Effort leaderboard")" Subtitle="@AppLocalization.Text("report.effortLeaderboardDesc", "People carrying the most tracked effort.")" Insight="@AppLocalization.Text("report.effortLeaderboardInsight", "Names highlight capacity allocation, not performance judgement.")"> + <TopRiskList Items="EmployeeEffortLeaders" EmptyText="@AppLocalization.Text("report.noEmployeeEffort", "No employee effort data available.")" /> + </ReportChartCard> + </div> + </div> + } + else + { + <!-- Table --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between"> + <h3 class="text-sm font-semibold text-slate-700"> + @AppLocalization.Text("pagination.showing", "Showing") <span class="text-violet-600 font-bold">@AppLocalization.Number(FilteredIssues.Count())</span> @AppLocalization.Text("report.timesheetEntries", "timesheet issue entry(s)") + </h3> + </div> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.issue", "Issue")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.taskProject", "Task & Project")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.assignee", "Assignee")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.estimatedHours", "Est. Hours")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actualHours", "Actual Hours")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.dueDate", "Due Date")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.status", "Status")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (PagedIssues.Any()) + { + @foreach (var entry in PagedIssues.Select((issue, index) => (issue, index))) + { + var issue = entry.issue; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4"> + <div> + <p class="text-sm font-semibold text-slate-900">@Content.GetIssueTitle(issue)</p> + @if (!string.IsNullOrEmpty(Content.GetIssueDescription(issue))) + { + <p class="text-xs text-slate-400 mt-0.5 truncate max-w-[200px]">@Content.GetIssueDescription(issue)</p> + } + </div> + </td> + <td class="px-6 py-4"> + <div class="text-sm text-slate-800 font-medium">@Content.GetText(issue.TaskTitle, issue.TaskTitleMy)</div> + <div class="text-xs text-slate-400 mt-0.5">@Content.GetText(issue.ProjectName, issue.ProjectNameMy)</div> + </td> + <td class="px-6 py-4"> + @if (Content.GetText(issue.AssignedToName, issue.AssignedToNameMy) is { Length: > 0 } assigneeName) + { + <div class="flex items-center gap-2"> + <span class="flex h-7 w-7 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold"> + @assigneeName[0].ToString().ToUpper() + </span> + <span class="text-sm text-slate-700">@Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)</span> + </div> + } + else + { + <span class="text-xs text-slate-400">@AppLocalization.Text("common.unassigned", "Unassigned")</span> + } + </td> + <td class="px-6 py-4 text-sm font-semibold text-slate-800"> + @AppLocalization.Number(issue.EstimatedHours ?? 0, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs") + </td> + <td class="px-6 py-4 text-sm font-semibold @(GetVariance(issue) > 0 ? "text-rose-600" : "text-emerald-600")"> + @AppLocalization.Number(issue.ActualHours ?? 0, "N1") @AppLocalization.Text("common.hoursAbbrev", "hrs") + </td> + <td class="px-6 py-4 text-sm text-slate-600"> + @DisplayFormats.Date(issue.DueDate) + </td> + <td class="px-6 py-4"> + @{ + var (statusLabel, statusClass) = issue.StatusId switch { + AppTaskStatus.InProgress => (AppLocalization.StatusLabel(AppTaskStatus.InProgress), "bg-blue-50 text-blue-700"), + AppTaskStatus.Done => (AppLocalization.StatusLabel(AppTaskStatus.Done), "bg-emerald-50 text-emerald-700"), + _ => (AppLocalization.StatusLabel(AppTaskStatus.Todo), "bg-slate-100 text-slate-600") + }; + } + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @statusClass">@statusLabel</span> + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="8" Icon="clock" Title="@AppLocalization.Text("report.noEffortEntries", "No effort entries")" Subtitle="@AppLocalization.Text("report.noEffortEntriesDesc", "No timesheet effort entries matched your filters.")" /> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="FilteredIssues.Count()" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } + } +</div> + +@code { + private bool isLoading = true; + private bool isChartView = false; + private List<IssueDto> allIssues = new(); + private List<ProjectDto> 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<long> 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<IssueDto> FilteredIssues + { + get + { + var result = allIssues.AsEnumerable(); + + if (appliedProjectId > 0) + result = result.Where(i => i.ProjectId == appliedProjectId); + + if (appliedStatus != "0" && Enum.TryParse<AppTaskStatus>(appliedStatus, out var status)) + result = result.Where(i => i.StatusId == status); + + if (!string.IsNullOrWhiteSpace(appliedSearch)) + { + var q = appliedSearch.Trim().ToLower(); + result = result.Where(i => + ContainsSearch(i.Title, i.TitleMy, q) || + ContainsSearch(i.Description, i.DescriptionMy, q) || + ContainsSearch(i.AssignedToName, i.AssignedToNameMy, q) || + ContainsSearch(i.TaskTitle, i.TaskTitleMy, 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 static bool ContainsSearch(string? english, string? burmese, string search) + => (!string.IsNullOrWhiteSpace(english) && english.Contains(search, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrWhiteSpace(burmese) && burmese.Contains(search, StringComparison.OrdinalIgnoreCase)); + + private IEnumerable<IssueDto> PagedIssues => FilteredIssues.Skip((currentPage - 1) * pageSize).Take(pageSize); + + protected override async Task OnInitializedAsync() + { + var client = ApiClient.CreateClient(); + try + { + var issuesTask = client.GetFromJsonAsync<Result<List<IssueDto>>>("Report/issues", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("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<List<long>>("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 + ? AppLocalization.Text("report.actualEffortAboveEstimate", "Actual effort is {hours}h above estimate.").Replace("{hours}", AppLocalization.Number(varianceHours)) + : AppLocalization.Text("report.actualEffortUnderEstimate", "Actual effort is {hours}h under estimate.").Replace("{hours}", AppLocalization.Number(Math.Abs(varianceHours))); + private string UtilizationInsight => utilizationPercent > 115 + ? AppLocalization.Text("report.utilizationAbovePlan", "Utilization is above plan; review estimates and blockers.") + : utilizationPercent < 70 ? AppLocalization.Text("report.utilizationBelowPlan", "Tracked actual effort is below plan; confirm work is being logged.") : AppLocalization.Text("report.utilizationCloseToPlan", "Utilization is close to the planning range."); + private string EstimateAccuracyInsight => OverEstimateCount > OnOrUnderEstimateCount + ? AppLocalization.Text("report.accuracyExceedingEstimate", "More issues are exceeding estimate than staying within it.") + : AppLocalization.Text("report.accuracyWithinEstimate", "Most issues are within or under their estimates."); + + private List<StackedSegment> EffortComparisonSegments => new() + { + new(AppLocalization.Text("report.actual", "Actual"), (double)Math.Max(0, actualHours), "#3b82f6"), + new(AppLocalization.Text("report.remainingEstimate", "Remaining estimate"), (double)Math.Max(0, totalHours - actualHours), "#dbeafe") + }; + + private List<ReportChartItem> EstimateAccuracyDonutItems => new() + { + new(AppLocalization.Text("report.onOrUnder", "On / Under"), OnOrUnderEstimateCount, "#10b981"), + new(AppLocalization.Text("report.overLabel", "Over"), OverEstimateCount, "#ef4444") + }; + + private List<StackedSegment> ProjectEffortSegments => FilteredIssues + .GroupBy(i => Content.GetText(i.ProjectName, i.ProjectNameMy)) + .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<TopRiskItem> EmployeeEffortLeaders => FilteredIssues + .GroupBy(i => string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy)) + .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, + $"{AppLocalization.Number(actual)} {AppLocalization.Text("common.hoursAbbrev", "hrs")} {AppLocalization.Text("report.actual", "actual").ToLowerInvariant()} · {AppLocalization.Number(estimated)} {AppLocalization.Text("common.hoursAbbrev", "hrs")} {AppLocalization.Text("report.estimated", "estimated").ToLowerInvariant()}", + score, + $"{AppLocalization.Number(g.Count())} {AppLocalization.Text("report.issuesLabel", "issues")}", + tone, + $"{AppLocalization.Number(g.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)))} {AppLocalization.Text("report.overEstimateDesc", "over estimate")}"); + }) + .OrderByDescending(item => item.Score) + .Take(5) + .ToList(); + + private static readonly string[] ChartPalette = ["#7c3aed", "#3b82f6", "#06b6d4", "#10b981", "#f59e0b", "#ef4444"]; + + private List<ProgressListChartItem> EstimatedVsActualItems => new[] + { + new ProgressListChartItem(AppLocalization.Text("report.estimated", "Estimated"), (double)totalHours, $"{AppLocalization.Number(totalHours)}", "#7c3aed"), + new ProgressListChartItem(AppLocalization.Text("report.actual", "Actual"), (double)actualHours, $"{AppLocalization.Number(actualHours)}", "#06b6d4") + }.ToList(); + + private double EstimatedVsActualMax => GetMax(EstimatedVsActualItems); + + private List<ProgressListChartItem> EffortByProjectItems => FilteredIssues + .GroupBy(i => Content.GetText(i.ProjectName, i.ProjectNameMy)) + .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, $"{AppLocalization.Number(hours, "N1")}", "#7c3aed"); + }) + .ToList(); + + private double EffortByProjectMax => GetMax(EffortByProjectItems); + + private List<ProgressListChartItem> EffortByEmployeeItems => FilteredIssues + .GroupBy(i => string.IsNullOrWhiteSpace(Content.GetText(i.AssignedToName, i.AssignedToNameMy)) ? AppLocalization.Text("common.unassigned", "Unassigned") : Content.GetText(i.AssignedToName, i.AssignedToNameMy)) + .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, $"{AppLocalization.Number(hours)}", "#7c3aed"); + }) + .ToList(); + + private double EffortByEmployeeMax => GetMax(EffortByEmployeeItems); + + private List<ProgressListChartItem> EstimateAccuracyItems => new[] + { + new ProgressListChartItem(AppLocalization.Text("report.onOrUnder", "On / Under"), FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0)), AppLocalization.Number(FilteredIssues.Count(i => (i.ActualHours ?? 0) <= (i.EstimatedHours ?? 0))), "#10b981"), + new ProgressListChartItem(AppLocalization.Text("report.overLabel", "Over"), FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0)), AppLocalization.Number(FilteredIssues.Count(i => (i.ActualHours ?? 0) > (i.EstimatedHours ?? 0))), "#ef4444") + }.ToList(); + + private double EstimateAccuracyMax => GetMax(EstimateAccuracyItems); + + private static double GetMax(IEnumerable<ProgressListChartItem> 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 = AppLocalization.StatusLabel(issue.StatusId); + + return new object?[] + { + Content.GetText(issue.Title, issue.TitleMy), + Content.GetText(issue.TaskTitle, issue.TaskTitleMy), + Content.GetText(issue.ProjectName, issue.ProjectNameMy), + string.IsNullOrWhiteSpace(Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)) ? "Unassigned" : Content.GetText(issue.AssignedToName, issue.AssignedToNameMy), + 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) is { } project ? Content.GetProjectName(project) : "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<string> + { + $"Search: {(string.IsNullOrWhiteSpace(appliedSearch) ? "All" : appliedSearch)}", + $"Project: {(appliedProjectId > 0 && allProjects.FirstOrDefault(p => p.Id == appliedProjectId) is { } project ? Content.GetProjectName(project) : "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 = AppLocalization.StatusLabel(issue.StatusId); + + return new[] + { + Content.GetText(issue.Title, issue.TitleMy), + Content.GetText(issue.TaskTitle, issue.TaskTitleMy), + Content.GetText(issue.ProjectName, issue.ProjectNameMy), + string.IsNullOrWhiteSpace(Content.GetText(issue.AssignedToName, issue.AssignedToNameMy)) ? "Unassigned" : Content.GetText(issue.AssignedToName, issue.AssignedToNameMy), + (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..d3d42b7a7e9a36d1a7b922dda83c4645f317cecf --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Roles/Roles.razor @@ -0,0 +1,1398 @@ +@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 + + + +<PageTitle>@AppLocalization.Text("role.rolesAccess", "Roles & Access")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex items-center justify-between"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.Text("role.rolesAccess", "Roles & Access")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.Text("role.rolesAccessDesc", "Manage roles, assign access and permissions, and control visibility.")</p> + </div> + @if (canCreateRole) + { + <button @onclick="OpenCreateModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="plus" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("role.newRole", "New Role") + </button> + } + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("role.loadingRoles", "Loading roles...")" /> + } + else + { + <!-- Search Panel --> + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm mb-4"> + <div class="flex items-center gap-3"> + <div class="relative flex-1 max-w-md"> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("role.searchByName", "Search roles by name...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2.5 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + </div> + </div> + + <!-- Roles Table --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.role", "Role")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.description", "Description")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.created", "Created")</th> + <th class="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actions", "Actions")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (PagedRoles.Any()) + { + @foreach (var entry in PagedRoles.Select((role, index) => (role, index))) + { + var role = entry.role; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4"> + <div class="flex items-center space-x-3"> + <span class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600"> + <i data-lucide="shield-check" class="h-4 w-4"></i> + </span> + <span class="text-sm font-semibold text-slate-900">@Content.GetRoleName(role)</span> + </div> + </td> + <td class="px-6 py-4 text-sm text-slate-500 max-w-xs truncate">@(Content.GetText(role.Description, role.DescriptionMy) is { Length: > 0 } description ? description : AppLocalization.Text("common.na", "N/A"))</td> + <td class="px-6 py-4 text-sm text-slate-400">@DisplayFormats.Date(role.CreatedAt)</td> + <td class="px-6 py-4 text-right"> + <div class="flex items-center justify-end space-x-2"> + @if (canManageAccess) + { + <button @onclick="() => OpenAccessModal(role)" + class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 transition-all" + title="@AppLocalization.Text("role.manageAccess", "Manage Access")"> + <i data-lucide="shield-check" class="h-3.5 w-3.5"></i> + @AppLocalization.Text("role.access", "Access") + </button> + } + @if (canEditRole) + { + <button @onclick="() => OpenEditModal(role)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="@AppLocalization.Text("action.edit", "Edit")"> + <i data-lucide="pencil" class="h-4 w-4"></i> + </button> + } + @if (canDeleteRole) + { + <button @onclick="() => RequestDeleteConfirmation(role.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="@AppLocalization.Text("action.delete", "Delete")"> + <i data-lucide="trash-2" class="h-4 w-4"></i> + </button> + } + </div> + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="5" Icon="shield-check" Title="@AppLocalization.Text("role.noRolesYet", "No roles yet")" Subtitle="@AppLocalization.Text("role.createFirstRole", "Create your first role to get started.")" /> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="TotalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } +</div> + +@* ===== Create / Edit Role Modal ===== *@ +@if (showRoleModal) +{ + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> + <div class="bg-white rounded-2xl shadow-lg w-full mx-4 flex flex-col overflow-hidden" + style="max-width: @(isEditing ? "30rem" : "58rem"); max-height: 88vh;"> + + <!-- Header --> + <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50 shrink-0"> + <div class="flex items-center gap-3"> + <span class="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-600 text-white"> + <i data-lucide="@(isEditing ? "pencil" : "shield-plus")" class="h-4 w-4"></i> + </span> + <h3 class="text-base font-semibold text-slate-900"> + @(isEditing ? AppLocalization.Text("role.editRole", "Edit Role") : AppLocalization.Text("role.createNewRole", "Create New Role")) + </h3> + </div> + <button @onclick="CloseRoleModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"> + <i data-lucide="x" class="h-5 w-5"></i> + </button> + </div> + + <!-- Body: two panels on Create, single on Edit --> + <div class="flex overflow-hidden flex-1 min-h-0"> + + <!-- LEFT: Role Details --> + <div class="p-6 space-y-4 overflow-y-auto @(isEditing ? "w-full" : "w-5/12") border-r border-slate-100"> + <BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formNameMy))" + EnglishComplete="@(!string.IsNullOrWhiteSpace(formName))" + BurmeseAiGenerated="@burmeseGenerated" + EnglishAiGenerated="@englishGenerated" + BurmeseNeedsReview="@burmeseNeedsReview" + EnglishNeedsReview="@englishNeedsReview"> + <BurmeseContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">အမည် <span class="text-red-500">*</span></label> + <input @bind="formNameMy" type="text" placeholder="အမည်" class="w-full rounded-lg border @(isRoleNameError && string.IsNullOrWhiteSpace(formName) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + <BilingualFieldWarning Text="@formNameMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveRoleNameToEnglish" /> + <BilingualTranslationAction SourceText="@formNameMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formName))" Generated="GenerateRoleEnglishName" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">ဖော်ပြချက်</label> + <textarea @bind="formDescriptionMy" rows="3" placeholder="ဖော်ပြချက်" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 resize-none"></textarea> + <BilingualTranslationAction SourceText="@formDescriptionMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescription))" Generated="GenerateRoleEnglishDescription" /> + </BurmeseContent> + <EnglishContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.name", "Name") <span class="text-red-500">*</span></label> + <input @bind="formName" type="text" placeholder="@AppLocalization.Text("role.namePlaceholder", "e.g. Manager, Viewer")" class="w-full rounded-lg border @(isRoleNameError && string.IsNullOrWhiteSpace(formNameMy) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" /> + <BilingualFieldWarning Text="@formName" ExpectedBurmese="false" MoveToOtherLanguage="MoveRoleNameToBurmese" /> + <BilingualTranslationAction SourceText="@formName" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formNameMy))" Generated="GenerateRoleBurmeseName" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label> + <textarea @bind="formDescription" rows="4" placeholder="@AppLocalization.Text("role.descriptionPlaceholder", "What can this role do?")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent resize-none"></textarea> + <BilingualTranslationAction SourceText="@formDescription" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescriptionMy))" Generated="GenerateRoleBurmeseDescription" /> + </EnglishContent> + </BilingualEditorTabs> + @if (isRoleNameError) { <p class="mt-1 text-xs text-red-500">@roleNameErrorMsg</p> } + @if (!string.IsNullOrEmpty(roleError)) + { + <div class="p-3 rounded-lg bg-red-50 border border-red-100 flex items-start gap-2"> + <i data-lucide="alert-circle" class="h-4 w-4 text-red-500 mt-0.5 shrink-0"></i> + <p class="text-sm text-red-600">@roleError</p> + </div> + } + @if (!isEditing) + { + <p class="text-xs text-slate-400 flex items-center gap-1"> + <i data-lucide="info" class="h-3.5 w-3.5 shrink-0"></i> + @AppLocalization.Text("role.createHint", "Choose what this role can see and do on the right. It will be saved with the role.") + </p> + } + </div> + + <!-- RIGHT: Access (Create only) --> + @if (!isEditing) + { + <div class="w-7/12 flex flex-col min-h-0 border-l border-slate-100"> + <div class="px-5 py-3 border-b border-slate-100 bg-slate-50/60 shrink-0 flex items-center justify-between gap-3"> + <div class="flex items-center gap-2 min-w-0"> + <i data-lucide="shield-check" class="h-4 w-4 text-violet-500"></i> + <span class="text-sm font-semibold text-slate-700">@AppLocalization.Text("role.assignAccess", "Assign Access")</span> + <span class="hidden md:inline text-xs text-slate-400">@AppLocalization.Text("role.accessHint", "Pages control where the role can go. Actions control what it can do.")</span> + </div> + <div class="flex items-center gap-2"> + <div class="relative w-36 sm:w-44"> + <i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i> + <input @bind-value="formAccessSearch" @bind-value:event="oninput" type="text" placeholder="@AppLocalization.Text("common.search", "Search")" + class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" /> + </div> + @if (formAccessItems.Any()) + { + <label class="flex items-center gap-1.5 cursor-pointer group whitespace-nowrap"> + <input type="checkbox" + checked="@IsAllFormAccessSelected" + @onchange="ToggleSelectAllFormAccess" + class="h-3.5 w-3.5 rounded accent-violet-600" /> + <span class="text-xs text-slate-500 group-hover:text-violet-600 transition-colors">@AppLocalization.Text("common.all", "All")</span> + </label> + } + </div> + </div> + + <div class="overflow-y-auto flex-1 px-4 py-4"> + @if (isLoadingFormAccessItems) + { + <div class="flex items-center justify-center py-10"> + <svg class="animate-spin h-5 w-5 text-violet-500" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> + </svg> + </div> + } + else if (!formAccessItems.Any()) + { + <p class="text-sm text-slate-400 text-center py-8">@AppLocalization.Text("role.noAccessItemsFound", "No access items found in database.")</p> + } + else + { + <div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between"> + <span>@AppLocalization.Text("role.pagePermissionHint", "Page access lets the role open this section. Permissions let it use the buttons and actions inside.")</span> + <span class="font-medium text-slate-400">@formSelectedAccessCodes.Count @AppLocalization.Text("common.selected", "selected")</span> + </div> + + @if (GetRootAccessItems(true).Any()) + { + <div class="grid grid-cols-1 xl:grid-cols-2 gap-3"> + @foreach (var parent in GetRootAccessItems(true)) + { + var children = GetChildAccessItems(parent, true).ToList(); + var parentChecked = formSelectedAccessCodes.Contains(parent.MenuCode); + + <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <label class="flex items-start gap-3 px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-50 transition-colors"> + <input type="checkbox" checked="@parentChecked" + @onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: true)" + class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" /> + <div class="flex min-w-0 flex-1 items-start gap-2"> + @if (!string.IsNullOrEmpty(parent.Icon)) + { + <i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i> + } + <div class="min-w-0 flex-1"> + <div class="flex items-center gap-2"> + <span class="text-sm font-semibold text-slate-800 truncate">@Content.GetMenuName(parent)</span> + <span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">@AppLocalization.Text("common.module", "အပိုင်း")</span> + </div> + <div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div> + </div> + </div> + </label> + + <div class="border-t border-slate-100 px-3 py-3"> + @if (parent.Permissions.Any()) + { + <div class="mb-3"> + <div class="mb-2 flex items-center justify-between px-1"> + <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">@AppLocalization.Text("common.actions", "Actions")</span> + <span class="text-[11px] text-slate-400">@AppLocalization.Text("role.crudPermissions", "CRUD permissions for this page")</span> + </div> + <div class="flex flex-wrap gap-2"> + @foreach (var action in parent.Permissions) + { + var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode); + <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all + @(isActionChecked + ? "bg-violet-50 border-violet-200 text-violet-700" + : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")"> + <input type="checkbox" checked="@isActionChecked" + @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)" + class="h-3.5 w-3.5 rounded accent-violet-600" /> + <span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span> + </label> + } + </div> + </div> + } + + @if (children.Any()) + { + <div class="space-y-2"> + @foreach (var child in children) + { + var childChecked = formSelectedAccessCodes.Contains(child.MenuCode); + <div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3"> + <label class="flex items-center gap-2 cursor-pointer"> + <input type="checkbox" checked="@childChecked" + @onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: true)" + class="h-4 w-4 rounded border-slate-300 accent-violet-600" /> + <span class="text-sm font-medium text-slate-700">@Content.GetMenuName(child)</span> + </label> + + @if (child.Permissions.Any()) + { + <div class="mt-2 flex flex-wrap gap-2 pl-6"> + @foreach (var action in child.Permissions) + { + var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode); + <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all + @(isActionChecked + ? "bg-violet-50 border-violet-200 text-violet-700" + : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")"> + <input type="checkbox" checked="@isActionChecked" + @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)" + class="h-3 w-3 rounded accent-violet-600" /> + <span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span> + </label> + } + </div> + } + </div> + } + </div> + } + else if (!parent.Permissions.Any()) + { + <p class="text-xs text-slate-400">@AppLocalization.Text("role.nothingInsidePage", "Nothing else is available inside this page.")</p> + } + </div> + </div> + } + </div> + } + else + { + <p class="py-10 text-center text-sm text-slate-400">@AppLocalization.Text("role.noPagesMatchSearch", "No pages or permissions match your search.")</p> + } + } + </div> + </div> + } + </div> + + <!-- Footer --> + <div class="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50 shrink-0"> + <span class="text-xs text-slate-400"> + @if (!isEditing && formSelectedAccessCodes.Count > 0) + { + <span class="text-violet-600 font-medium">@AppLocalization.Text("role.selectedItems", "{count} selected item(s)").Replace("{count}", formSelectedAccessCodes.Count.ToString())</span> + <span> @AppLocalization.Text("role.willBeSaved", "will be saved")</span> + } + </span> + <div class="flex items-center gap-3"> + <button @onclick="CloseRoleModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"> + @AppLocalization.Text("common.cancel", "Cancel") + </button> + <button @onclick="RequestSaveConfirmation" disabled="@isSaving" + class="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm"> + <svg class="animate-spin h-4 w-4 @(isSaving ? "" : "hidden")" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> + <span class="@(isSaving ? "" : "hidden")">@AppLocalization.Text("common.saving", "Saving...")</span> + + <i data-lucide="@(isEditing ? "check" : "shield-plus")" class="h-4 w-4 @(isSaving ? "hidden" : "")"></i> + <span class="@(isSaving ? "hidden" : "")">@(isEditing ? AppLocalization.Text("action.update", "Update") : AppLocalization.Text("action.create", "Create"))</span> + </button> + </div> + </div> + </div> + </div> +} + +@* ===== Assign Access Modal ===== *@ +@* ===== Role Access Modal ===== *@ +@if (showAccessModal) +{ + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> + <div class="bg-white rounded-2xl shadow-lg w-full max-w-6xl mx-4 overflow-hidden flex flex-col" style="max-height: 88vh;"> + + <!-- Header --> + <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50"> + <div class="flex items-center gap-3"> + <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-100 text-violet-600"> + <i data-lucide="shield-check" class="h-4 w-4"></i> + </div> + <div> + <h3 class="text-base font-semibold text-slate-900">@AppLocalization.Text("role.access", "Access") - <span class="text-violet-700">@selectedRoleName</span></h3> + <p class="text-xs text-slate-500">@AppLocalization.Text("role.accessConfigured", "Configure access and permissions")</p> + </div> + </div> + <button @onclick="CloseAccessModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-white hover:text-slate-600 transition-all"> + <i data-lucide="x" class="h-5 w-5"></i> + </button> + </div> + + <!-- Select All Bar --> + <div class="px-6 py-3 border-b border-slate-100 bg-slate-50/60 flex items-center justify-between gap-3"> + <div class="flex items-center gap-2 min-w-0"> + <label class="flex items-center gap-2 cursor-pointer"> + <input type="checkbox" checked="@IsAllAccessSelected" @onchange="ToggleSelectAllAccess" + class="h-4 w-4 rounded border-slate-300 accent-violet-600" /> + <span class="text-sm font-medium text-slate-700">@AppLocalization.Text("common.selectAll", "Select All")</span> + </label> + <span class="hidden md:inline text-xs text-slate-400">@AppLocalization.Text("role.permissionsHint", "Menus open pages. Permissions unlock actions.")</span> + </div> + <div class="flex items-center gap-2"> + <div class="relative w-36 sm:w-48"> + <i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i> + <input @bind-value="accessSearch" @bind-value:event="oninput" type="text" placeholder="@AppLocalization.Text("common.search", "Search")" + class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" /> + </div> + <span class="text-xs text-slate-400 whitespace-nowrap">@selectedAccessCodes.Count @AppLocalization.Text("common.selected", "selected")</span> + </div> + </div> + + <!-- Menu Items Body --> + <div class="overflow-y-auto flex-1 px-4 py-4"> + @if (isLoadingAccessItems) + { + <div class="flex items-center justify-center py-12"> + <svg class="animate-spin h-6 w-6 text-violet-500" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> + </svg> + </div> + } + else if (!accessItems.Any()) + { + <div class="text-center py-12"> + <i data-lucide="shield-check" class="h-10 w-10 mx-auto text-slate-300 mb-3"></i> + <p class="text-sm text-slate-500">@AppLocalization.Text("role.noAccessItemsFound", "No access items found in database.")</p> + </div> + } + else + { + <div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between"> + <span>@AppLocalization.Text("role.permissionsHint", "Menus open pages. Permissions unlock actions.")</span> + <span class="font-medium text-slate-400">@selectedAccessCodes.Count @AppLocalization.Text("common.selected", "selected")</span> + </div> + + @if (GetRootAccessItems(false).Any()) + { + <div class="grid grid-cols-1 xl:grid-cols-2 gap-3"> + @foreach (var parent in GetRootAccessItems(false)) + { + var children = GetChildAccessItems(parent, false).ToList(); + var parentChecked = selectedAccessCodes.Contains(parent.MenuCode); + + <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <label class="flex items-start gap-3 px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-50 transition-colors"> + <input type="checkbox" checked="@parentChecked" + @onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: false)" + class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" /> + <div class="flex min-w-0 flex-1 items-start gap-2"> + @if (!string.IsNullOrEmpty(parent.Icon)) + { + <i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i> + } + <div class="min-w-0 flex-1"> + <div class="flex items-center gap-2"> + <span class="text-sm font-semibold text-slate-800 truncate">@Content.GetMenuName(parent)</span> + <span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">@AppLocalization.Text("common.module", "အပိုင်း")</span> + </div> + <div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div> + </div> + </div> + </label> + + <div class="border-t border-slate-100 px-3 py-3"> + @if (parent.Permissions.Any()) + { + <div class="mb-3"> + <div class="mb-2 flex items-center justify-between px-1"> + <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">@AppLocalization.Text("common.actions", "Actions")</span> + <span class="text-[11px] text-slate-400">@AppLocalization.Text("role.crudPermissions", "CRUD permissions for this page")</span> + </div> + <div class="flex flex-wrap gap-2"> + @foreach (var action in parent.Permissions) + { + var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode); + <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all + @(isActionChecked + ? "bg-violet-50 border-violet-200 text-violet-700" + : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")"> + <input type="checkbox" checked="@isActionChecked" + @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)" + class="h-3.5 w-3.5 rounded accent-violet-600" /> + <span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span> + </label> + } + </div> + </div> + } + + @if (children.Any()) + { + <div class="space-y-2"> + @foreach (var child in children) + { + var childChecked = selectedAccessCodes.Contains(child.MenuCode); + <div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3"> + <label class="flex items-center gap-2 cursor-pointer"> + <input type="checkbox" checked="@childChecked" + @onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: false)" + class="h-4 w-4 rounded border-slate-300 accent-violet-600" /> + <span class="text-sm font-medium text-slate-700">@Content.GetMenuName(child)</span> + </label> + + @if (child.Permissions.Any()) + { + <div class="mt-2 flex flex-wrap gap-2 pl-6"> + @foreach (var action in child.Permissions) + { + var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode); + <label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all + @(isActionChecked + ? "bg-violet-50 border-violet-200 text-violet-700" + : "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")"> + <input type="checkbox" checked="@isActionChecked" + @onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)" + class="h-3 w-3 rounded accent-violet-600" /> + <span>@Content.GetPermissionName(action.ActionName, action.ActionNameMy)</span> + </label> + } + </div> + } + </div> + } + </div> + } + else if (!parent.Permissions.Any()) + { + <p class="text-xs text-slate-400">@AppLocalization.Text("role.nothingInsidePage", "Nothing else is available inside this page.")</p> + } + </div> + </div> + } + </div> + } + else + { + <p class="py-10 text-center text-sm text-slate-400">@AppLocalization.Text("role.noPagesMatchSearch", "No pages or permissions match your search.")</p> + } + } + </div> + + <!-- Footer --> + @if (!string.IsNullOrEmpty(accessSaveError)) + { + <div class="px-6 pb-2"> + <div class="p-3 rounded-lg bg-red-50 border border-red-100"> + <p class="text-sm text-red-600">@accessSaveError</p> + </div> + </div> + } + @if (!string.IsNullOrEmpty(accessSaveSuccess)) + { + <div class="px-6 pb-2"> + <div class="p-3 rounded-lg bg-emerald-50 border border-emerald-100"> + <p class="text-sm text-emerald-700">@accessSaveSuccess</p> + </div> + </div> + } + + <div class="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50"> + <span class="text-xs text-slate-400"> + <i data-lucide="info" class="h-3.5 w-3.5 inline mr-1 text-slate-400"></i> + @AppLocalization.Text("role.changeApplyAfterSave", "Changes apply immediately after saving.") + </span> + <div class="flex items-center gap-3"> + <button @onclick="CloseAccessModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"> + @AppLocalization.Text("common.cancel", "Cancel") + </button> + <button @onclick="SaveAccess" disabled="@isSavingAccess" + class="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm"> + <svg class="animate-spin h-4 w-4 @(isSavingAccess ? "" : "hidden")" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> + <span class="@(isSavingAccess ? "" : "hidden")">@AppLocalization.Text("common.saving", "Saving...")</span> + + <i data-lucide="save" class="h-4 w-4 @(isSavingAccess ? "hidden" : "")"></i> + <span class="@(isSavingAccess ? "hidden" : "")">@AppLocalization.Text("role.saveAccess", "Save Access")</span> + </button> + </div> + </div> + </div> + </div> +} + +@* ===== Confirm Dialog ===== *@ +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@confirmTitle" + Message="@confirmMessage" + ConfirmText="@confirmButtonText" + Icon="@confirmIcon" + IconBgClass="@confirmIconBg" + IconTextClass="@confirmIconText" + ButtonClass="@confirmButtonClass" + HideCancel="@confirmHideCancel" + OnConfirm="HandleConfirmAction" + OnCancel="CloseConfirmDialog" /> + +@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<Task>? confirmAction; + private List<RoleDto> roleList = new(); + private List<UserDto> 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<RoleDto> PagedRoles => roleList; + + // Role form + private long editingId; + private string formName = ""; + private string? formNameMy; + private string? formDescription; + private string? formDescriptionMy; + private bool burmeseGenerated; + private bool englishGenerated; + private bool burmeseNeedsReview; + private bool englishNeedsReview; + + private void MoveRoleNameToEnglish() { formName = formNameMy ?? string.Empty; } + private void MoveRoleNameToBurmese() { formNameMy = formName; } + private void MarkGenerated(bool english) + { + if (english) { englishGenerated = true; englishNeedsReview = true; } + else { burmeseGenerated = true; burmeseNeedsReview = true; } + } + private Task GenerateRoleEnglishName(TranslationResult result) { formName = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateRoleBurmeseName(TranslationResult result) { formNameMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateRoleEnglishDescription(TranslationResult result) { formDescription = result.TranslatedText; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateRoleBurmeseDescription(TranslationResult result) { formDescriptionMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + + // Inline access items in Create form + private List<AccessMenuDto> formAccessItems = new(); + private HashSet<string> 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<AccessMenuDto> accessItems = new(); + private HashSet<string> 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<PagedResult<RoleDto>>(BuildRolesUrl(), Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync<List<UserDto>>("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<string> + { + $"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 = ""; + formNameMy = null; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + formDescription = null; + formDescriptionMy = 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<List<AccessMenuDto>>("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; + formNameMy = role.NameMy; + formDescription = role.Description; + formDescriptionMy = role.DescriptionMy; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + 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<string>(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<AccessMenuDto> 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<AccessMenuDto> 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<AccessMenuDto> source, string search) + { + if (MatchesSearch(menu.MenuName, search) || MatchesSearch(menu.MenuNameMy, search) || MatchesSearch(menu.MenuCode, search) || MatchesSearch(menu.MenuUrl, search)) + { + return true; + } + + if (menu.Permissions.Any(permission => + MatchesSearch(permission.ActionName, search) || + MatchesSearch(permission.ActionNameMy, 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<AccessMenuDto> 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<AccessMenuDto> source) + { + return source.FirstOrDefault(m => string.Equals(m.MenuCode, menuCode, StringComparison.OrdinalIgnoreCase)); + } + + private static void EnsureMenuPathSelected(string menuCode, IReadOnlyList<AccessMenuDto> source, ISet<string> 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<AccessMenuDto> source, ISet<string> 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<AccessMenuDto> source, ISet<string> 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<AccessMenuDto> source, ISet<string> 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<AccessMenuDto> source, ISet<string> 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<AccessMenuDto> source, ISet<string> 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<AccessMenuDto> source, ISet<string> 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<AccessMenuDto> GetAutoVisibleChildren(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> 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.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) && string.IsNullOrWhiteSpace(formNameMy)) + { + isRoleNameError = true; + roleNameErrorMsg = ResultMessages.RoleNameRequired; + hasError = true; + } + + if (hasError) return; + + if (isEditing) + { + confirmTitle = AppLocalization.Text("role.updateTitle", "Update Role?"); + confirmMessage = AppLocalization.Text("role.updateConfirm", "Are you sure you want to update this role?"); + 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("role.createTitle", "Create Role?"); + confirmMessage = AppLocalization.Text("role.createConfirm", "Are you sure you want to create this role?"); + 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 = SaveRole; + showConfirmDialog = true; + } + + private void RequestDeleteConfirmation(long id) + { + confirmTitle = AppLocalization.Text("role.deleteTitle", "Delete Role?"); + confirmMessage = AppLocalization.Text("role.deleteConfirm", "Are you sure you want to delete this role? 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 = () => 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) && string.IsNullOrWhiteSpace(formNameMy)) + { + return; + } + + isSaving = true; + roleError = null; + var client = ApiClient.CreateClient(); + + try + { + if (isEditing) + { + var dto = new UpdateRoleDto { Name = formName, NameMy = formNameMy, Description = formDescription, DescriptionMy = formDescriptionMy }; + var response = await client.PutAsJsonAsync($"Role/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToUpdateRole; return; } + } + else + { + NormalizeAccessSelection(formAccessItems, formSelectedAccessCodes); + var dto = new CreateRoleDto + { + Name = formName, + NameMy = formNameMy, + Description = formDescription, + DescriptionMy = formDescriptionMy, + AccessCodes = formSelectedAccessCodes.ToList() + }; + var response = await client.PostAsJsonAsync("Role", dto); + var res = await response.Content.ReadFromJsonAsync<Result<RoleDto>>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToCreateRole; return; } + } + CloseRoleModal(); + await LoadRoles(); + successMessage = isEditing ? AppLocalization.Text("role.updated", "Role updated successfully!") : AppLocalization.Text("role.created", "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 = AppLocalization.Text("role.cannotDeleteTitle", "Cannot Delete Role"); + confirmMessage = AppLocalization.Text("role.cannotDeleteUsers", "You can't delete this role because there are {count} users in this role.").Replace("{count}", usersCount.ToString()); + confirmButtonText = AppLocalization.Text("common.ok", "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<Result>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + confirmTitle = AppLocalization.Text("role.cannotDeleteTitle", "Cannot Delete Role"); + confirmMessage = res?.ErrorMessage ?? AppLocalization.Text("role.failedDelete", "Failed to delete role."); + confirmButtonText = AppLocalization.Text("common.ok", "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 = AppLocalization.Text("role.deleted", "Role deleted successfully!"); + } + catch (Exception ex) + { + Console.WriteLine($"Delete error: {ex.Message}"); + confirmTitle = AppLocalization.Text("common.error", "Error"); + confirmMessage = $"Delete error: {ex.Message}"; + confirmButtonText = AppLocalization.Text("common.ok", "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 = Content.GetRoleName(role); + 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<List<AccessMenuDto>>("Menu/access-items", Serialization.CaseInsensitive) ?? new(); + + // Load currently assigned access items for this role + var assignedResult = await client.GetFromJsonAsync<Result<List<string>>>($"Role/{role.Id}/access", Serialization.CaseInsensitive); + selectedAccessCodes = new HashSet<string>(assignedResult?.Value ?? new(), StringComparer.OrdinalIgnoreCase); + NormalizeAccessSelection(accessItems, selectedAccessCodes); + } + catch (Exception ex) + { + Console.WriteLine($"Load access items error: {ex.Message}"); + accessSaveError = AppLocalization.Text("role.failedLoadAccess", "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<string>(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 = AppLocalization.Text("role.accessSaved", "Access saved successfully."); + } + else + { + var body = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + accessSaveError = $"{AppLocalization.Text("role.failedSaveAccess", "Failed to save access.")} {(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..22d6b064be49a779f9d3758ec5d06312318393b4 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/KanbanBoard.razor @@ -0,0 +1,704 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("kanbanBoard", "Kanban Board")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex items-center justify-between"> + <div class="flex items-center space-x-4"> + @if (ProjectId > 0) + { + <button @onclick="GoBack" class="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"> + <i data-lucide="arrow-left" class="h-5 w-5"></i> + </button> + } + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("kanbanBoard", "Kanban Board")</h2> + <p class="text-slate-500 mt-1 font-medium">@(project != null ? Content.GetProjectName(project) : (ProjectId > 0 ? $"{AppLocalization.Text("common.project", "Project")} #{ProjectId}" : AppLocalization.Text("common.allProjects", "All Projects")))</p> + </div> + </div> + <div class="flex flex-wrap items-center gap-3"> + <select value="@selectedProjectId" @onchange="OnProjectFilterChanged" class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allProjects", "All Projects")</option> + @foreach (var p in projects) + { + <option value="@p.Id">@Content.GetProjectName(p)</option> + } + </select> + <button @onclick="ToggleArchivedView" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="@(showArchivedTasks ? "layout-grid" : "archive")" class="h-4 w-4 mr-2 text-slate-500"></i> + @(showArchivedTasks ? AppLocalization.Text("task.backToBoard", "Back to Board") : AppLocalization.Text("task.archivedTasks", "Archived Tasks")) + </button> + @if (canUpdateTask && !showArchivedTasks) + { + <button @onclick="RequestArchiveDoneConfirmation" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="archive" class="h-4 w-4 mr-2 text-slate-500"></i> + @AppLocalization.Text("action.archiveDone", "Archive Done") + </button> + } + @if (canCreateTask && !showArchivedTasks) + { + <button @onclick="OpenCreateTaskModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="plus" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("task.newTask", "New Task") + </button> + } + </div> + </div> + + @if (!string.IsNullOrWhiteSpace(boardError)) + { + <div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700"> + @boardError + </div> + } + + @if (isLoading) + { + <div class="flex items-center justify-center py-20"> + <div class="flex items-center space-x-3 text-slate-500"> + <svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> + <span class="text-sm font-medium">@AppLocalization.Text("task.loadingBoard", "Loading board...")</span> + </div> + </div> + } + else + { + <!-- Kanban Columns --> + <div class="mb-2 text-sm text-slate-500"> + @(showArchivedTasks ? AppLocalization.Text("task.viewingArchived", "Viewing archived tasks.") : AppLocalization.Text("task.dragHint", "Drag tasks between columns to update their status.")) + </div> + <div class="grid grid-cols-1 @(showArchivedTasks ? "md:grid-cols-1" : "md:grid-cols-3") gap-6"> + @foreach (var column in VisibleColumns) + { + var tasksInColumn = VisibleTasks.Where(t => t.StatusId == column.StatusId).ToList(); + var columnPage = GetColumnPage(column.StatusId, tasksInColumn.Count); + var pagedTasks = tasksInColumn + .Skip((columnPage - 1) * kanbanPageSize) + .Take(kanbanPageSize) + .ToList(); + var columnTotalPages = GetColumnTotalPages(tasksInColumn.Count); + <div @key="column.StatusId" class="flex flex-col rounded-xl border border-slate-200 bg-slate-50/50 overflow-hidden min-h-[400px]"> + <!-- Column Header --> + <div class="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-100"> + <div class="flex items-center space-x-2"> + <span class="h-2.5 w-2.5 rounded-full @column.DotColor"></span> + <h3 class="text-sm font-semibold text-slate-900">@AppLocalization.StatusLabel(column.StatusId)</h3> + </div> + <span class="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">@tasksInColumn.Count</span> + </div> + + <!-- Cards --> + <div class="flex-1 p-3 space-y-3 overflow-y-auto transition-colors @((!showArchivedTasks && dropTargetStatus == column.StatusId) ? "bg-slate-100" : "")" + @ondrop="() => HandleDrop(column.StatusId)" + @ondragenter="() => HandleDragEnter(column.StatusId)" + @ondragleave="HandleDragLeave" + @ondragover="HandleDragOver" + @ondragover:preventDefault="true"> + @if (pagedTasks.Any()) + { + @foreach (var task in pagedTasks) + { + <div @key="task.Id" class="select-none rounded-lg border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow @((canUpdateTask && !showArchivedTasks) ? "cursor-grab active:cursor-grabbing" : "cursor-default")" + data-task-id="@task.Id" + draggable="@((canUpdateTask && !showArchivedTasks).ToString().ToLowerInvariant())" + @ondragstart="() => HandleDragStart(task)" + @ondragend="HandleDragEnd"> + <div class="flex items-start justify-between"> + <h4 class="text-sm font-semibold text-slate-900">@Content.GetTaskTitle(task)</h4> + <span class="text-xs px-2 py-0.5 rounded-full font-medium @GetPriorityBadge(task.PriorityId)">@GetPriorityLabel(task.PriorityId)</span> + </div> + @if (!string.IsNullOrEmpty(Content.GetTaskDescription(task))) + { + <p class="text-xs text-slate-500 mt-1.5 line-clamp-2">@Content.GetTaskDescription(task)</p> + } + <div class="flex items-center justify-between mt-3 pt-3 border-t border-slate-50"> + <span class="text-xs text-slate-400">@AppLocalization.Text("task.due", "Due") @DisplayFormats.Date(task.DueDate)</span> + @if (showArchivedTasks) + { + <span class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">@AppLocalization.Text("task.archived", "Archived")</span> + } + </div> + </div> + } + } + else + { + <div class="flex items-center justify-center py-8 h-full"> + <p class="text-xs text-slate-400">@AppLocalization.Text("task.dropTasksHere", "Drop tasks here")</p> + </div> + } + </div> + + <div class="border-t border-slate-100 bg-white px-3 py-2"> + <div class="flex flex-wrap items-center justify-between gap-2"> + <div class="text-[11px] text-slate-500"> + @if (tasksInColumn.Count == 0) + { + <span>@AppLocalization.Text("task.noTasks", "No tasks")</span> + } + else + { + var from = (columnPage - 1) * kanbanPageSize + 1; + var to = Math.Min(columnPage * kanbanPageSize, tasksInColumn.Count); + <span><span class="font-semibold text-slate-700">@from-@to</span> @AppLocalization.Text("task.of", "of") <span class="font-semibold text-slate-700">@tasksInColumn.Count</span></span> + } + </div> + <div class="flex items-center gap-2"> + <select value="@kanbanPageSize" + @onchange="HandleKanbanPageSizeChanged" + class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-[11px] text-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-600 transition-all"> + <option value="5">5</option> + <option value="10">10</option> + <option value="20">20</option> + </select> + <button @onclick="() => ChangeColumnPage(column.StatusId, columnPage - 1)" + disabled="@(columnPage <= 1)" + class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-slate-50 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-40" + title="@AppLocalization.Text("task.previousPage", "Previous page")"> + <i data-lucide="chevron-left" class="h-3.5 w-3.5"></i> + </button> + <span class="min-w-10 text-center text-[11px] font-semibold text-slate-600">@columnPage / @columnTotalPages</span> + <button @onclick="() => ChangeColumnPage(column.StatusId, columnPage + 1)" + disabled="@(columnPage >= columnTotalPages)" + class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-slate-50 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-40" + title="@AppLocalization.Text("task.nextPage", "Next page")"> + <i data-lucide="chevron-right" class="h-3.5 w-3.5"></i> + </button> + </div> + </div> + </div> + </div> + } + </div> + } +</div> + +@* ===== Create Task Modal ===== *@ +@if (showTaskModal) +{ + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> + <div class="bg-white rounded-2xl shadow-lg w-full max-w-lg mx-4 overflow-hidden"> + <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100"> + <h3 class="text-lg font-semibold text-slate-900">@AppLocalization.Text("task.newTask", "New Task")</h3> + <button @onclick="CloseTaskModal" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"> + <i data-lucide="x" class="h-5 w-5"></i> + </button> + </div> + <div class="p-6 space-y-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.project", "Project")</label> + <select @bind="taskProjectId" disabled="@(ProjectId > 0)" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all disabled:bg-slate-50 disabled:text-slate-500"> + <option value="0">@AppLocalization.Text("task.selectProject", "Select project...")</option> + @foreach (var p in projects) + { + <option value="@p.Id">@Content.GetProjectName(p)</option> + } + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("field.title", "Title")</label> + <input @bind="taskTitleMy" type="text" placeholder="ခေါင်းစဉ်" class="mb-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all" /> + <input @bind="taskTitle" type="text" placeholder="@AppLocalization.Text("task.taskTitle", "Task title")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label> + <textarea @bind="taskDescriptionMy" rows="2" placeholder="ဖော်ပြချက်" class="mb-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all"></textarea> + <textarea @bind="taskDescription" rows="3" placeholder="@AppLocalization.Text("task.taskDescription", "Task description")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all"></textarea> + </div> + <div class="grid grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.priority", "Priority")</label> + <select @bind="taskPriorityId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"> + <option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option> + <option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option> + <option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option> + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.dueDate", "Due Date")</label> + <input @bind="taskDueDate" type="date" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + </div> + @if (!string.IsNullOrEmpty(taskError)) + { + <div class="p-3 rounded-lg bg-red-50 border border-red-100"> + <p class="text-sm text-red-600">@taskError</p> + </div> + } + </div> + <div class="flex items-center justify-end space-x-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50"> + <button @onclick="CloseTaskModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">@AppLocalization.Text("common.cancel", "Cancel")</button> + <button @onclick="RequestCreateConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors"> + @(isSaving ? AppLocalization.Text("common.creating", "Creating...") : AppLocalization.Text("task.createTask", "Create Task")) + </button> + </div> + </div> + </div> +} + +@* ===== Confirm Dialog ===== *@ +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@confirmTitle" + Message="@confirmMessage" + ConfirmText="@confirmButtonText" + Icon="@confirmIcon" + IconBgClass="@confirmIconBg" + IconTextClass="@confirmIconText" + ButtonClass="@confirmButtonClass" + OnConfirm="HandleConfirmAction" + OnCancel="CloseConfirmDialog" /> + +@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<Task>? confirmAction; + private List<TaskDto> tasks = new(); + private List<ProjectDto> projects = new(); + private long selectedProjectId = 0; + private int kanbanPageSize = 5; + private Dictionary<AppTaskStatus, int> columnPages = new(); + + // Task form + private long taskProjectId; + private string taskTitle = ""; + private string? taskTitleMy; + private string? taskDescription; + private string? taskDescriptionMy; + private TaskPriority taskPriorityId = TaskPriority.Medium; + private DateTime taskDueDate = DateTime.Today.AddDays(7); + + private List<KanbanColumn> 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<TaskDto> VisibleTasks => + selectedProjectId > 0 + ? tasks.Where(t => t.ProjectId == selectedProjectId) + : tasks; + + private IEnumerable<KanbanColumn> VisibleColumns => + showArchivedTasks + ? columns.Where(column => column.StatusId == AppTaskStatus.Done) + : columns; + + private int GetColumnTotalPages(int totalCount) => + Math.Max(1, (int)Math.Ceiling(totalCount / (double)kanbanPageSize)); + + private int GetColumnPage(AppTaskStatus statusId, int totalCount) + { + var totalPages = GetColumnTotalPages(totalCount); + if (!columnPages.TryGetValue(statusId, out var page)) + { + page = 1; + } + + page = Math.Clamp(page, 1, totalPages); + columnPages[statusId] = page; + return page; + } + + private void ChangeColumnPage(AppTaskStatus statusId, int page) + { + var totalCount = VisibleTasks.Count(t => t.StatusId == statusId); + var totalPages = GetColumnTotalPages(totalCount); + columnPages[statusId] = Math.Clamp(page, 1, totalPages); + } + + private void HandleKanbanPageSizeChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var size) && size > 0 && size != kanbanPageSize) + { + kanbanPageSize = size; + ResetColumnPages(); + } + } + + private void ResetColumnPages() + { + columnPages.Clear(); + } + + 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<List<TaskDto>>(showArchivedTasks ? "Task/archived" : "Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("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; + ResetColumnPages(); + } + 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 = AppLocalization.Text("task.failedMove", "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 = AppLocalization.Text("task.failedMove", "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; + ResetColumnPages(); + 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; + ResetColumnPages(); + } + + private void OpenCreateTaskModal() + { + taskProjectId = selectedProjectId > 0 ? selectedProjectId : projects.FirstOrDefault()?.Id ?? 0; + taskTitle = ""; + taskTitleMy = null; + taskDescription = null; + taskDescriptionMy = 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) && string.IsNullOrWhiteSpace(taskTitleMy)) + { + taskError = AppLocalization.Text("validation.taskTitleRequired", "Task title is required."); + return; + } + if (taskProjectId == 0) + { + taskError = AppLocalization.Text("validation.selectProjectRequired", "Please select a project."); + return; + } + + confirmTitle = AppLocalization.Text("task.createTitle", "Create Task?"); + confirmMessage = AppLocalization.Text("task.createConfirm", "Are you sure you want to create this task?"); + 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 = 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) && string.IsNullOrWhiteSpace(taskTitleMy)) + { + taskError = AppLocalization.Text("validation.taskTitleRequired", "Task title is required."); + return; + } + + isSaving = true; + taskError = null; + var client = ApiClient.CreateClient(); + + try + { + var dto = new CreateTaskDto + { + Title = taskTitle, + TitleMy = taskTitleMy, + Description = taskDescription, + DescriptionMy = taskDescriptionMy, + 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); + ResetColumnPages(); + successMessage = AppLocalization.Text("task.created", "Task created successfully!"); + } + else + { + taskError = AppLocalization.Text("task.failedCreate", "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 = AppLocalization.Text("task.noDoneToArchive", "No Done tasks to archive."); + return; + } + + confirmTitle = AppLocalization.Text("task.archiveDoneTitle", "Archive Done Tasks?"); + confirmMessage = selectedProjectId > 0 + ? AppLocalization.Text("task.archiveDoneInProject", "Archive {count} Done task(s) in this project? Archived tasks will be hidden from active views.").Replace("{count}", doneCount.ToString()) + : AppLocalization.Text("task.archiveDoneAll", "Archive {count} Done task(s) across all visible projects? Archived tasks will be hidden from active views.").Replace("{count}", doneCount.ToString()); + confirmButtonText = AppLocalization.Text("task.archive", "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<Result<int>>(Serialization.CaseInsensitive); + if (response.IsSuccessStatusCode && result?.IsSuccess == true) + { + await LoadTasks(); + successMessage = result.Value == 1 + ? AppLocalization.Text("task.archivedOneDone", "Archived 1 Done task.") + : AppLocalization.Text("task.archivedManyDone", "Archived {count} Done tasks.").Replace("{count}", (result?.Value ?? 0).ToString()); + return; + } + + boardError = result?.ErrorMessage ?? AppLocalization.Text("task.failedArchiveDone", "Failed to archive Done tasks."); + } + catch (Exception ex) + { + Console.WriteLine($"Archive done tasks error: {ex.Message}"); + boardError = AppLocalization.Text("task.failedArchiveDone", "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) => AppLocalization.PriorityLabel(priorityId); + + 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..cbc42b54b01e3d693c3e3a3a520de379a5d9b733 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskAssign.razor @@ -0,0 +1,681 @@ +@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 AuthenticationStateProvider AuthStateProvider +@inject MenuAuthorizationService MenuAuthorization + +<PageTitle>@AppLocalization.PageTitle("taskAssignment", "Task Assignment")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("taskAssignment", "Task Assignment")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.PageDescription("taskAssignment", "Assign tasks to project members and track task owners.")</p> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingTasksAndUsers", "Loading tasks and users...")" /> + } + else + { + <!-- Project Filter Bar --> + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"> + <div class="flex items-center gap-4"> + <label class="text-sm font-semibold text-slate-700">@AppLocalization.Text("task.filterByProject", "Filter by Project"):</label> + <div class="w-64"> + <select @bind="selectedProjectIdInput" + class="w-full rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allProjects", "All Projects")</option> + @foreach (var p in projects) + { + <option value="@p.Id">@Content.GetProjectName(p)</option> + } + </select> + </div> + <button @onclick="ApplyProjectFilter" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetProjectFilter" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-1.5 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-3.5 w-3.5 text-slate-500"></i> + </button> + </div> + </div> + + <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> + <!-- Left Panel: Tasks List --> + <div class="lg:col-span-1 rounded-xl border border-slate-200 bg-white shadow-sm flex flex-col h-[550px] overflow-hidden"> + <div class="p-4 border-b border-slate-100 bg-slate-50/60"> + <h3 class="font-bold text-slate-800 text-sm mb-3">@AppLocalization.PageTitle("tasks", "Tasks")</h3> + <div class="relative flex gap-2"> + <div class="relative flex-1"> + <input @bind="taskSearchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchTasks", "Search tasks...")" + class="w-full rounded-lg border border-slate-200 pl-9 pr-3 py-1.5 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <i data-lucide="search" class="absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400"></i> + </div> + <button @onclick="ApplyTaskSearch" class="inline-flex items-center rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shrink-0"> + <i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetTaskSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-1.5 text-slate-700 hover:bg-slate-50 transition-colors shrink-0"> + <i data-lucide="rotate-ccw" class="h-3.5 w-3.5 text-slate-500"></i> + </button> + </div> + </div> + <div class="flex-1 overflow-y-auto divide-y divide-slate-100"> + @if (FilteredTasks.Any()) + { + @foreach (var task in FilteredTasks) + { + var isSelected = SelectedTask?.Id == task.Id; + var proj = projects.FirstOrDefault(p => p.Id == task.ProjectId); + <button @onclick="() => SelectTask(task)" + class="w-full text-left p-4 hover:bg-slate-50/60 transition-colors flex flex-col gap-1.5 @(isSelected ? "bg-violet-50/60 border-l-4 border-violet-600 pl-3" : "")"> + <div class="flex items-center justify-between"> + <span class="text-xs font-mono font-bold text-slate-400">TSK-@AppLocalization.Number(task.Id)</span> + <span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-semibold @GetStatusBadge(task.StatusId)"> + @GetStatusLabel(task.StatusId) + </span> + </div> + <span class="text-sm font-semibold text-slate-900 @(isSelected ? "text-violet-700" : "")">@Content.GetTaskTitle(task)</span> + <div class="flex items-center justify-between text-[10px] text-slate-500"> + <span>@(proj != null ? Content.GetProjectName(proj) : AppLocalization.Text("common.noProject", "No Project"))</span> + <span class="flex items-center"> + <i data-lucide="user" class="h-3 w-3 mr-1"></i> + @{ var assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo); } + @(assignee != null ? Content.GetUserFullName(assignee) : AppLocalization.Text("common.unassigned", "Unassigned")) + </span> + </div> + </button> + } + } + else + { + <div class="p-8 text-center text-slate-400 text-xs">@AppLocalization.Text("common.noTasksFound", "No tasks found.")</div> + } + </div> + </div> + + <!-- Right Panel: Assignee Picker --> + <div class="lg:col-span-2 rounded-xl border border-slate-200 bg-white shadow-sm flex flex-col h-[550px] overflow-hidden"> + @if (SelectedTask == null) + { + <div class="flex-1 flex flex-col items-center justify-center p-8 text-center bg-slate-50/30"> + <div class="h-12 w-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3"> + <i data-lucide="check-square" class="h-6 w-6"></i> + </div> + <h4 class="text-sm font-bold text-slate-800">@AppLocalization.Text("common.noTaskSelected", "No Task Selected")</h4> + <p class="text-xs text-slate-500 mt-1 max-w-sm">@AppLocalization.Text("task.selectTaskPrompt", "Select a task from the left panel to assign it to a team member.")</p> + </div> + } + else + { + <!-- Selected Task Details --> + <div class="p-6 border-b border-slate-100 bg-slate-50"> + <div class="flex items-center gap-2 mb-2"> + <span class="text-[10px] font-bold text-violet-600 uppercase tracking-wider">@AppLocalization.Text("common.currentlyAssigningTask", "Currently Assigning Task")</span> + <span class="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">TSK-@AppLocalization.Number(SelectedTask.Id)</span> + </div> + <h3 class="text-xl font-bold text-slate-900">@Content.GetTaskTitle(SelectedTask)</h3> + @if (!string.IsNullOrEmpty(Content.GetTaskDescription(SelectedTask))) + { + <p class="text-xs text-slate-500 mt-1 line-clamp-2">@Content.GetTaskDescription(SelectedTask)</p> + } + + <div class="flex flex-wrap items-center gap-3 mt-4"> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold @GetStatusBadge(SelectedTask.StatusId)"> + @AppLocalization.Text("common.status", "Status"): @GetStatusLabel(SelectedTask.StatusId) + </span> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold @GetPriorityBadge(SelectedTask.PriorityId)"> + @AppLocalization.Text("common.priority", "Priority"): @GetPriorityLabel(SelectedTask.PriorityId) + </span> + <span class="text-xs text-slate-500"> + <i data-lucide="calendar" class="h-3 w-3 inline mr-1 text-slate-400"></i> + @DisplayFormats.Date(SelectedTask.DueDate) + </span> + </div> + </div> + + <div class="px-6 py-3 border-b border-slate-100 bg-white"> + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> + <div> + <span class="text-[10px] font-bold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("task.selectedAssignee", "Selected Assignee")</span> + <div class="mt-1"> + @{ + var targetAssignee = TargetAssignee; + } + @if (targetAssignee != null) + { + <span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-slate-700"> + <span class="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-[10px] font-bold"> + @(targetAssignee.FirstName.Length > 0 ? targetAssignee.FirstName[0].ToString().ToUpper() : "")@(targetAssignee.LastName.Length > 0 ? targetAssignee.LastName[0].ToString().ToUpper() : "") + </span> + <span>@Content.GetUserFullName(targetAssignee)</span> + <span class="text-slate-400">@@@targetAssignee.Username</span> + </span> + } + else + { + <span class="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600"> + <i data-lucide="user-minus" class="h-3.5 w-3.5 text-slate-400"></i> + @AppLocalization.Text("common.unassigned", "Unassigned") + </span> + } + </div> + </div> + </div> + </div> + + <!-- Filter & User Selection --> + <div class="px-6 py-3 border-b border-slate-100 bg-slate-50/30 flex flex-col sm:flex-row sm:items-center justify-between gap-3"> + <div class="relative max-w-xs flex-1 flex gap-2"> + <div class="relative flex-1"> + <input @bind="userSearchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchTeamMembers", "Search team members...")" + class="w-full rounded-lg border border-slate-200 pl-9 pr-3 py-1.5 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <i data-lucide="search" class="absolute left-3 top-2.5 h-3.5 w-3.5 text-slate-400"></i> + </div> + <button @onclick="ApplyUserSearch" class="inline-flex items-center rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-700 transition-colors shrink-0"> + <i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i> @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetUserSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-1.5 text-slate-700 hover:bg-slate-50 transition-colors shrink-0"> + <i data-lucide="rotate-ccw" class="h-3.5 w-3.5 text-slate-500"></i> + </button> + </div> + @if (isAdminUser) + { + <div class="w-40 shrink-0"> + <select @bind="selectedRoleId" class="w-full rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allRoles", "All Roles")</option> + @foreach (var r in roles) + { + <option value="@r.Id">@Content.GetRoleName(r)</option> + } + </select> + </div> + } + else + { + <div class="text-xs font-semibold text-violet-700 bg-violet-50 border border-violet-100 px-3 py-1.5 rounded-lg shrink-0"> + @AppLocalization.Text("project.employeesInProject", "Employees in this project") + </div> + } + <div class="text-xs font-semibold text-slate-600 shrink-0"> + @AppLocalization.Text("common.selected", "Selected"): <span class="text-violet-700 font-bold">@(TargetAssigneeId.HasValue ? allUsers.FirstOrDefault(u => u.Id == TargetAssigneeId)?.Username ?? AppLocalization.Text("common.selected", "Selected") : AppLocalization.Text("common.unassigned", "Unassigned"))</span> + </div> + </div> + + <!-- Members picker --> + <div class="flex-1 overflow-y-auto p-6"> + <div class="grid grid-cols-1 md:grid-cols-2 gap-3"> + <!-- Unassigned option --> + <label class="flex items-center gap-3 p-3 rounded-xl border border-dashed border-slate-200 bg-white hover:bg-slate-50/50 cursor-pointer transition-all select-none @(!TargetAssigneeId.HasValue ? "border-violet-600 bg-violet-50/10" : "")"> + <input type="radio" name="assigneeRadio" checked="@(!TargetAssigneeId.HasValue)" + @onchange="() => SetTargetAssignee(null)" + class="h-4 w-4 text-violet-600 focus:ring-violet-500" /> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 text-xs font-bold shrink-0"> + <i data-lucide="user-minus" class="h-4 w-4"></i> + </span> + <div class="min-w-0 flex-1"> + <p class="text-sm font-semibold text-slate-800">@AppLocalization.Text("common.unassigned", "Unassigned")</p> + <p class="text-xs text-slate-400">@AppLocalization.Text("common.removeOwnerFromTask", "Remove owner from this task")</p> + </div> + </label> + + @foreach (var user in FilteredUsers) + { + var isSelected = TargetAssigneeId == user.Id; + <label class="flex items-center gap-3 p-3 rounded-xl border border-slate-100 bg-white hover:bg-slate-50/50 hover:border-slate-200 cursor-pointer transition-all shadow-sm select-none @(isSelected ? "border-violet-600 bg-violet-50/10" : "")"> + <input type="radio" name="assigneeRadio" checked="@isSelected" + @onchange="() => SetTargetAssignee(user.Id)" + class="h-4 w-4 text-violet-600 focus:ring-violet-500" /> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0"> + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + </span> + <div class="min-w-0 flex-1"> + <p class="text-sm font-semibold text-slate-800 truncate">@Content.GetUserFullName(user)</p> + <p class="text-xs text-slate-400 truncate">@@@user.Username</p> + </div> + </label> + } + </div> + </div> + + <!-- Action bar --> + <div class="p-6 border-t border-slate-100 bg-slate-50/50 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <div> + @if (!string.IsNullOrEmpty(statusMessage)) + { + <p class="text-sm font-medium @(isError ? "text-red-600" : "text-emerald-700")"> + <i data-lucide="@(isError ? "alert-circle" : "check-circle")" class="h-4 w-4 inline mr-1.5"></i> + @statusMessage + </p> + } + </div> + @if (canSaveAssignments) + { + <button @onclick="RequestSaveConfirmation" disabled="@isSaving" + class="w-full sm:w-auto inline-flex items-center justify-center rounded-lg bg-violet-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm"> + <i data-lucide="user-check" class="h-4 w-4 mr-2"></i> + @(isSaving ? AppLocalization.Text("common.loadingSpinner", "Loading...") : AppLocalization.Text("common.saveAssignment", "Save Assignment")) + </button> + } + </div> + } + </div> + </div> + } +</div> + +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@AppLocalization.Text("task.saveAssignmentTitle", "Save Assignment?")" + Message="@AppLocalization.Text("task.saveAssignmentConfirm", "Are you sure you want to save this task assignment?")" + ConfirmText="@AppLocalization.Text("issue.yesSave", "Yes, Save")" + Icon="user-check" + OnConfirm="ConfirmSaveAssignment" + OnCancel="CloseConfirmDialog" /> + +@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<TaskDto> tasks = new(); + private List<ProjectDto> projects = new(); + private List<UserDto> allUsers = new(); + private List<UserDto> projectMembers = new(); + private List<RoleDto> 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<List<TaskDto>>("Task", Serialization.CaseInsensitive); + var projectsTask = client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive); + var rolesTask = client.GetFromJsonAsync<List<RoleDto>>("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<TaskDto> 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) || + (!string.IsNullOrWhiteSpace(t.TitleMy) && t.TitleMy.Contains(taskSearch, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(t.Description) && t.Description.Contains(taskSearch, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(t.DescriptionMy) && t.DescriptionMy.Contains(taskSearch, StringComparison.OrdinalIgnoreCase))); + } + return result; + } + } + + private IEnumerable<UserDto> 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 => Content.GetUserFullName(u).Contains(userSearch, StringComparison.OrdinalIgnoreCase) + || $"{u.FirstName} {u.LastName} {u.FirstNameMy} {u.LastNameMy}".Contains(userSearch, StringComparison.OrdinalIgnoreCase) + || u.Username.Contains(userSearch, StringComparison.OrdinalIgnoreCase)); + } + return result; + } + } + + private UserDto? TargetAssignee => + TargetAssigneeId.HasValue + ? allUsers.FirstOrDefault(u => u.Id == TargetAssigneeId.Value) + : null; + + 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<Result<IEnumerable<UserDto>>>($"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 = AppLocalization.Text("task.failedLoadProjectMembers", "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, + TitleMy = SelectedTask.TitleMy, + Description = SelectedTask.Description, + DescriptionMy = SelectedTask.DescriptionMy, + StatusId = SelectedTask.StatusId, + PriorityId = SelectedTask.PriorityId, + DueDate = SelectedTask.DueDate, + AssignedTo = TargetAssigneeId, + AssignedBy = SelectedTask.AssignedBy + }; + + var response = await client.PutAsJsonAsync($"Task/{SelectedTask.Id}", dto); + if (response.IsSuccessStatusCode) + { + SelectedTask.AssignedTo = TargetAssigneeId; + var taskInList = tasks.FirstOrDefault(t => t.Id == SelectedTask.Id); + if (taskInList != null) + { + taskInList.AssignedTo = TargetAssigneeId; + } + statusMessage = AppLocalization.Text("task.assigneeUpdated", "Task assignee updated successfully!"); + successMessage = AppLocalization.Text("common.assignmentSaved", "Assignment saved successfully!"); + isError = false; + } + else + { + var apiError = await ReadApiErrorAsync(response); + statusMessage = string.IsNullOrWhiteSpace(apiError) + ? AppLocalization.Text("task.failedUpdateAssignee", "Failed to update assignee.") + : AppLocalization.ErrorMessage(apiError, AppLocalization.Text("task.failedUpdateAssignee", "Failed to update assignee.")); + isError = true; + } + } + catch (Exception ex) + { + statusMessage = AppLocalization.ErrorMessage(ex.Message, AppLocalization.Text("task.failedUpdateAssignee", "Failed to update assignee.")); + isError = true; + } + finally + { + isSaving = false; + await JS.InvokeVoidAsync("initIcons"); + } + } + + private static async Task<string?> ReadApiErrorAsync(HttpResponseMessage response) + { + try + { + var body = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + try + { + var result = System.Text.Json.JsonSerializer.Deserialize<Result>(body, Serialization.CaseInsensitive); + if (!string.IsNullOrWhiteSpace(result?.ErrorMessage)) + { + return result.ErrorMessage; + } + } + catch + { + // Try validation/problem-details payloads below. + } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("errorMessage", out var errorMessage) && + errorMessage.ValueKind == System.Text.Json.JsonValueKind.String) + { + return errorMessage.GetString(); + } + + if (root.TryGetProperty("message", out var message) && + message.ValueKind == System.Text.Json.JsonValueKind.String) + { + return message.GetString(); + } + + if (root.TryGetProperty("errors", out var errors) && + errors.ValueKind == System.Text.Json.JsonValueKind.Object) + { + var messages = new List<string>(); + foreach (var property in errors.EnumerateObject()) + { + if (property.Value.ValueKind != System.Text.Json.JsonValueKind.Array) + { + continue; + } + + foreach (var item in property.Value.EnumerateArray()) + { + if (item.ValueKind == System.Text.Json.JsonValueKind.String) + { + messages.Add(item.GetString() ?? string.Empty); + } + } + } + + return string.Join(" ", messages.Where(message => !string.IsNullOrWhiteSpace(message))); + } + } + catch + { + return body; + } + + return body; + } + catch + { + return null; + } + } + + 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 => AppLocalization.Text("status.todo", "To Do"), + AppTaskStatus.InProgress => AppLocalization.Text("status.inprogress", "In Progress"), + AppTaskStatus.Done => AppLocalization.Text("status.done", "Done"), + _ => AppLocalization.Text("common.unknown", "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 => AppLocalization.Text("priority.low", "Low"), + TaskPriority.Medium => AppLocalization.Text("priority.medium", "Medium"), + TaskPriority.High => AppLocalization.Text("priority.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..eceef42f1271518651538883c657985584c87eb2 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/TaskDetails.razor @@ -0,0 +1,705 @@ +@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 + +<PageTitle>@AppLocalization.Text("page.taskDetails", "Task Details")</PageTitle> + +<div class="space-y-6"> + @if (ViewMode != "Embedded") + { + <!-- Breadcrumbs & Actions --> + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <div class="flex items-center space-x-2 text-sm text-slate-500"> + <a href="/dashboard" class="hover:text-violet-600 transition-colors">@AppLocalization.Text("common.workspace", "Workspace")</a> + <i data-lucide="chevron-right" class="h-3.5 w-3.5"></i> + <a href="/tasks" class="hover:text-violet-600 transition-colors">@AppLocalization.Text("common.task", "Tasks")</a> + <i data-lucide="chevron-right" class="h-3.5 w-3.5"></i> + <span class="text-slate-800 font-medium">@AppLocalization.Text("page.taskDetails", "Task Details")</span> + </div> + <a href="/tasks" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="arrow-left" class="h-4 w-4 mr-2 text-slate-500"></i> + @AppLocalization.Text("task.backToBoard", "Back to Board") + </a> + </div> + } + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("task.loadingDetails", "Loading task details...")" /> + } + else if (task == null) + { + <div class="rounded-2xl border border-slate-200 bg-white p-12 text-center"> + <i data-lucide="alert-circle" class="h-10 w-10 mx-auto text-red-500 mb-3"></i> + <h4 class="text-lg font-bold text-slate-800">@AppLocalization.Text("task.notFound", "Task Not Found")</h4> + <p class="text-sm text-slate-400 mt-1">@AppLocalization.Text("task.notFoundDesc", "The task you are looking for does not exist or has been deleted.")</p> + </div> + } + else + { + <!-- Task Header Card --> + <div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> + <div class="flex flex-wrap items-start justify-between gap-4"> + <div class="space-y-2 flex-1 min-w-0"> + <div class="flex flex-wrap items-center gap-2"> + <span class="text-xs font-bold font-mono text-slate-400">TASK-#@task.Id</span> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold @GetStatusBadge(task.StatusId)"> + @GetStatusLabel(task.StatusId) + </span> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold @GetPriorityBadge(task.PriorityId)"> + @GetPriorityLabel(task.PriorityId) + </span> + @if (project != null) + { + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-violet-50 text-violet-700 border border-violet-100"> + <i data-lucide="folder" class="h-3 w-3 mr-1.5"></i> + @Content.GetProjectName(project) + </span> + } + </div> + @if (isEditingTitle) + { + <div class="space-y-2 max-w-xl mt-1"> + <input @bind="tempTitleMy" type="text" placeholder="ခေါင်းစဉ်" class="w-full rounded-lg border border-slate-200 px-3 py-1 text-lg font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + <input @bind="tempTitle" type="text" placeholder="@AppLocalization.Text("field.title", "Title")" class="w-full rounded-lg border border-slate-200 px-3 py-1 text-lg font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + <div class="flex items-center gap-2"> + <button @onclick="CancelEditTitle" class="px-2.5 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 rounded-lg transition-colors">@AppLocalization.Text("common.cancel", "Cancel")</button> + <button @onclick="SaveTitle" disabled="@isSavingTitle" class="px-2.5 py-1 text-xs font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors shadow-sm">@AppLocalization.Text("common.save", "Save")</button> + </div> + </div> + } + else + { + <h2 class="text-xl font-bold text-slate-900 leading-tight flex items-center gap-2 group"> + @Content.GetTaskTitle(task) + @if (hasTaskManagePermission) + { + <button @onclick="StartEditTitle" class="opacity-0 group-hover:opacity-100 p-1 text-slate-400 hover:text-violet-600 transition-all rounded-lg hover:bg-slate-50" title="@AppLocalization.Text("task.editTitle", "Edit title")"> + <i data-lucide="pencil" class="h-4 w-4"></i> + </button> + } + </h2> + } + </div> + </div> + </div> + + <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> + <!-- Left Grid: Task Details & Description --> + <div class="lg:col-span-2 space-y-6"> + <!-- Description Card --> + <div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4"> + <div class="flex items-center justify-between"> + <h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-2"> + <i data-lucide="align-left" class="h-4 w-4"></i> @AppLocalization.Text("common.description", "Description") + </h3> + @if (hasTaskManagePermission && !isEditingDescription) + { + <button @onclick="StartEditDescription" class="inline-flex items-center text-xs font-semibold text-violet-600 hover:text-violet-800 transition-colors"> + <i data-lucide="pencil" class="h-3 w-3 mr-1"></i> @AppLocalization.Text("action.edit", "Edit") + </button> + } + </div> + @if (isEditingDescription) + { + <div class="space-y-3"> + <textarea @bind="tempDescriptionMy" rows="4" placeholder="ဖော်ပြချက်" class="w-full text-sm rounded-xl border border-slate-200 p-3 text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <textarea @bind="tempDescription" rows="4" placeholder="@AppLocalization.Text("common.description", "Description")" class="w-full text-sm rounded-xl border border-slate-200 p-3 text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <div class="flex items-center gap-2 justify-end"> + <button @onclick="CancelEditDescription" class="px-3 py-1.5 text-xs font-medium text-slate-500 hover:bg-slate-100 rounded-lg transition-colors">@AppLocalization.Text("common.cancel", "Cancel")</button> + <button @onclick="SaveDescription" disabled="@isSavingDescription" class="px-3 py-1.5 text-xs font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors shadow-sm"> + @(isSavingDescription ? AppLocalization.Text("task.saving", "Saving...") : AppLocalization.Text("common.save", "Save")) + </button> + </div> + </div> + } + else + { + <div class="text-sm text-slate-700 leading-relaxed min-h-[120px] whitespace-pre-wrap"> + @(string.IsNullOrWhiteSpace(Content.GetTaskDescription(task)) ? AppLocalization.Text("common.noDescription", "No description") : Content.GetTaskDescription(task)) + </div> + } + </div> + + <!-- Properties Grid (Pristine grid alignment in uniform 2-column layout) --> + <div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> + <h3 class="text-sm font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-2 mb-5"> + <i data-lucide="info" class="h-4 w-4"></i> @AppLocalization.Text("task.properties", "Properties") + </h3> + <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-5 text-sm font-sans"> + <!-- Status Property (Free for everyone) --> + <div class="flex flex-col gap-1.5"> + <span class="text-xs font-bold uppercase tracking-wider text-slate-400 font-sans">@AppLocalization.Text("common.status", "Status")</span> + <select value="@task.StatusId" @onchange="OnStatusChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm"> + <option value="@AppTaskStatus.Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="@AppTaskStatus.InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="@AppTaskStatus.Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + </select> + </div> + + <!-- Priority Property --> + <div class="flex flex-col gap-1.5"> + <span class="text-xs font-bold uppercase tracking-wider text-slate-400 font-sans">@AppLocalization.Text("common.priority", "Priority")</span> + @if (hasTaskManagePermission) + { + <select value="@task.PriorityId" @onchange="OnPriorityChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm"> + <option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option> + <option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option> + <option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option> + </select> + } + else + { + <div class="h-9 flex items-center px-1 text-sm text-slate-800 cursor-not-allowed select-none"> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold @GetPriorityBadge(task.PriorityId)"> + @GetPriorityLabel(task.PriorityId) + </span> + </div> + } + </div> + + <!-- Assignee Property --> + <div class="flex flex-col gap-1.5"> + <span class="text-xs font-bold uppercase tracking-wider text-slate-400 font-sans">@AppLocalization.Text("common.assignee", "Assignee")</span> + @if (hasTaskManagePermission) + { + <select value="@(task.AssignedTo ?? 0)" @onchange="OnAssigneeChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm"> + <option value="0">@AppLocalization.Text("common.unassigned", "Unassigned")</option> + @foreach (var u in allUsers) + { + <option value="@u.Id">@Content.GetUserFullName(u)</option> + } + </select> + } + else + { + <div class="h-9 flex items-center px-1 text-sm text-slate-800 cursor-not-allowed select-none gap-2"> + @if (assignee != null) + { + <span class="flex h-5 w-5 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-[10px] font-bold"> + @(assignee.FirstName.Length > 0 ? assignee.FirstName[0].ToString().ToUpper() : "") + </span> + <span class="font-medium text-slate-700">@Content.GetUserFullName(assignee)</span> + } + else + { + <span class="text-slate-400 italic">@AppLocalization.Text("common.unassigned", "Unassigned")</span> + } + </div> + } + </div> + + <!-- Due Date Property --> + <div class="flex flex-col gap-1.5"> + <span class="text-xs font-bold uppercase tracking-wider text-slate-400 font-sans">@AppLocalization.Text("common.dueDate", "Due Date")</span> + @if (hasTaskManagePermission) + { + <input type="date" value="@task.DueDate.ToString("yyyy-MM-dd")" @onchange="OnDueDateChanged" class="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent bg-white w-full transition-all cursor-pointer shadow-sm" /> + } + else + { + <div class="h-9 flex items-center px-1 text-sm text-slate-800 cursor-not-allowed select-none font-medium text-slate-700"> + <i data-lucide="calendar" class="h-4 w-4 mr-2 text-slate-400"></i> + @DisplayFormats.Date(task.DueDate) + </div> + } + </div> + + <!-- Created At Property --> + <div class="flex flex-col gap-1.5"> + <span class="text-xs font-bold uppercase tracking-wider text-slate-400 font-sans">@AppLocalization.Text("field.created", "Created At")</span> + <div class="h-9 flex items-center px-1 border-b border-transparent text-sm text-slate-800 cursor-default select-none font-medium text-slate-700"> + <i data-lucide="calendar-plus" class="h-4 w-4 mr-2 text-slate-400"></i> + @AppLocalization.LocalizeDigits(task.CreatedAt.ToString("dd-MM-yyyy HH:mm")) + </div> + </div> + </div> + </div> + </div> + + <!-- Right Grid: Comments --> + <div class="space-y-6"> + <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden flex flex-col h-[580px]"> + <!-- Header --> + <div class="border-b border-slate-100 bg-slate-50/50 p-4"> + <h3 class="text-sm font-bold text-slate-800 flex items-center gap-2"> + <i data-lucide="message-square" class="h-4 w-4 text-violet-600"></i> + @AppLocalization.Text("comment.comments", "Comments") (@AppLocalization.Number(comments.Count)) + </h3> + </div> + + <!-- Comments Body --> + <div class="flex-1 overflow-y-auto p-4 min-h-0"> + <div class="space-y-4 font-sans"> + @if (comments.Any()) + { + @foreach (var c in comments) + { + <div class="flex items-start gap-3"> + <span class="flex h-7 w-7 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0 mt-0.5"> + @(c.UserFullName.Length > 0 ? c.UserFullName[0].ToString().ToUpper() : "U") + </span> + <div class="flex-1 min-w-0 bg-slate-50 rounded-xl p-3 border border-slate-100"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs font-bold text-slate-800 truncate">@Content.GetText(c.UserFullName, c.UserFullNameMy)</span> + <span class="text-[9px] text-slate-400 shrink-0 font-sans">@AppLocalization.LocalizeDigits(c.CreatedAt.ToString("g"))</span> + </div> + <p class="text-xs text-slate-400 mt-0.5">@Content.GetText(c.UserRoleName, c.UserRoleNameMy)</p> + <div class="text-xs text-slate-700 mt-2 whitespace-pre-wrap break-words">@Content.GetText(c.Message, c.MessageMy)</div> + </div> + @if (c.UserId == currentUserId || isAdmin) + { + <button @onclick="() => ConfirmDeleteComment(c.Id)" class="p-1 text-slate-300 hover:text-rose-500 rounded-lg transition-colors shrink-0" title="@AppLocalization.Text("comment.deleteComment", "Delete comment")"> + <i data-lucide="trash-2" class="h-3.5 w-3.5"></i> + </button> + } + </div> + } + } + else + { + <div class="text-center py-12 text-slate-400 space-y-2"> + <i data-lucide="messages-square" class="h-8 w-8 mx-auto text-slate-300"></i> + <p class="text-xs font-sans">@AppLocalization.Text("comment.noComments", "No comments posted yet.")</p> + <p class="text-[10px] font-sans">@AppLocalization.Text("comment.startConversation", "Start the conversation below!")</p> + </div> + } + </div> + </div> + + <!-- Footer (Inputs) --> + <div class="p-4 border-t border-slate-100 bg-slate-50/50 font-sans"> + <div class="flex items-start gap-2"> + <div class="flex-1"> + <textarea @bind="newCommentMessageMy" rows="2" placeholder="မြန်မာမှတ်ချက်" class="mb-1 w-full rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" disabled="@isSavingComment"></textarea> + <textarea @bind="newCommentMessage" rows="2" placeholder="@AppLocalization.Text("comment.writeComment", "Write a comment...")" class="w-full rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" disabled="@isSavingComment"></textarea> + @if (!string.IsNullOrEmpty(commentError)) + { + <p class="text-[10px] text-red-500 mt-1 font-sans">@commentError</p> + } + </div> + <button @onclick="PostComment" disabled="@(isSavingComment || (string.IsNullOrWhiteSpace(newCommentMessage) && string.IsNullOrWhiteSpace(newCommentMessageMy)))" class="rounded-xl bg-violet-600 text-white p-2.5 hover:bg-violet-700 disabled:opacity-50 transition-all shadow-sm shrink-0"> + <i data-lucide="send" class="h-4 w-4"></i> + </button> + </div> + </div> + </div> + </div> + </div> + } +</div> + +<ConfirmDialog IsVisible="showConfirm" + Title="@confirmTitle" + Message="@confirmMessage" + ConfirmText="Yes, Delete" + Icon="trash-2" + IconBgClass="bg-rose-50" + IconTextClass="text-rose-500" + ButtonClass="bg-rose-600 hover:bg-rose-700" + OnConfirm="ExecuteConfirmAction" + OnCancel="CancelConfirm" /> + +@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<Task>? confirmAction; + + private bool isLoading = true; + private TaskDto? task; + private ProjectDto? project; + private UserDto? assignee; + private List<CommentDto> comments = new(); + private List<UserDto> allUsers = new(); + + private bool isAdmin = false; + private bool canEditTask = false; + private bool hasTaskManagePermission = false; + private long currentUserId = 0; + private string userRoleName = "Employee"; + + // Comments Form + private string newCommentMessage = ""; + private string? newCommentMessageMy; + 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 string tempDescriptionMy = ""; + + private bool isEditingTitle = false; + private bool isSavingTitle = false; + private string tempTitle = ""; + private string tempTitleMy = ""; + + 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<TaskDto>(Serialization.CaseInsensitive); + } + + var commentsResponse = commentsResponseTask.Result; + if (commentsResponse.IsSuccessStatusCode) + { + comments = await commentsResponse.Content.ReadFromJsonAsync<List<CommentDto>>(Serialization.CaseInsensitive) ?? new(); + } + + var usersResponse = usersResponseTask.Result; + if (usersResponse.IsSuccessStatusCode) + { + allUsers = await usersResponse.Content.ReadFromJsonAsync<List<UserDto>>(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<ProjectDto>(Serialization.CaseInsensitive); + } + + // Set assignee details + if (task.AssignedTo.HasValue) + { + assignee = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo.Value); + } + + } + } + catch (Exception ex) + { + Console.WriteLine($"Load task details error: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private async Task OnStatusChanged(ChangeEventArgs e) + { + if (task == null) return; + if (Enum.TryParse<AppTaskStatus>(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<TaskPriority>(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<UpdateTaskDto> applyChange) + { + if (task == null) return; + + var dto = new UpdateTaskDto + { + Title = task.Title, + TitleMy = task.TitleMy, + Description = task.Description, + DescriptionMy = task.DescriptionMy, + 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.TitleMy = dto.TitleMy; + task.Description = dto.Description; + task.DescriptionMy = dto.DescriptionMy; + 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 ?? ""; + tempDescriptionMy = task.DescriptionMy ?? ""; + isEditingDescription = true; + } + + private void CancelEditDescription() + { + isEditingDescription = false; + } + + private async Task SaveDescription() + { + isSavingDescription = true; + if (string.IsNullOrWhiteSpace(tempDescription) && string.IsNullOrWhiteSpace(tempDescriptionMy)) + { + isEditingDescription = false; + isSavingDescription = false; + return; + } + await UpdateTaskProperty(dto => + { + dto.Description = tempDescription.Trim(); + dto.DescriptionMy = tempDescriptionMy.Trim(); + }); + isEditingDescription = false; + isSavingDescription = false; + } + + private void StartEditTitle() + { + if (task == null) return; + tempTitle = task.Title; + tempTitleMy = task.TitleMy ?? ""; + isEditingTitle = true; + } + + private void CancelEditTitle() + { + isEditingTitle = false; + } + + private async Task SaveTitle() + { + if (string.IsNullOrWhiteSpace(tempTitle) && string.IsNullOrWhiteSpace(tempTitleMy)) return; + isSavingTitle = true; + await UpdateTaskProperty(dto => + { + dto.Title = tempTitle.Trim(); + dto.TitleMy = tempTitleMy.Trim(); + }); + isEditingTitle = false; + isSavingTitle = false; + } + + private async Task PostComment() + { + if (string.IsNullOrWhiteSpace(newCommentMessage) && string.IsNullOrWhiteSpace(newCommentMessageMy)) return; + + isSavingComment = true; + commentError = null; + try + { + var client = ApiClient.CreateClient(); + var response = await client.PostAsJsonAsync($"TaskDetails/tasks/{TaskId}/comments", new CreateCommentDto { Message = newCommentMessage, MessageMy = newCommentMessageMy }); + var res = await response.Content.ReadFromJsonAsync<Result<CommentDto>>(Serialization.CaseInsensitive); + + if (response.IsSuccessStatusCode && res != null && res.IsSuccess && res.Value != null) + { + comments.Add(res.Value); + newCommentMessage = ""; + newCommentMessageMy = null; + 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 = AppLocalization.Text("comment.deleteCommentTitle", "Delete Comment?"); + confirmMessage = AppLocalization.Text("comment.deleteCommentConfirm", "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<Result>(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) => AppLocalization.StatusLabel(statusId); + + 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) => AppLocalization.PriorityLabel(priorityId); + + 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..3da65a4b69054c2d268c4aa245cb1c6512d68eab --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Tasks/Tasks.razor @@ -0,0 +1,1027 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("tasks", "Tasks")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("tasks", "Tasks")</h2> + <p class="text-slate-500 mt-1">@(isAdmin ? AppLocalization.Text("task.manageAll", "View and manage all tasks across projects.") : AppLocalization.Text("task.yourAssigned", "Your assigned tasks."))</p> + </div> + <div class="flex items-center space-x-4"> + @if (canCreateTask) + { + <button @onclick="OpenCreateModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="plus" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("task.newTask", "New Task") + </button> + } + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingTasks", "Loading tasks...")" /> + } + else + { + <!-- Search and Filters Panel --> + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"> + <div class="flex flex-wrap items-center gap-4"> + <div class="relative flex-1 min-w-[200px]"> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchTasksByTitle", "Search tasks by title...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div class="w-44 shrink-0"> + <select @bind="filterProjectIdInput" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.allProjects", "All Projects")</option> + @foreach (var p in projects) + { + <option value="@p.Id">@Content.GetProjectName(p)</option> + } + </select> + </div> + <div class="w-40 shrink-0"> + <select @bind="filterStatusIdInput" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="">@AppLocalization.Text("common.allStatuses", "All Statuses")</option> + <option value="@AppTaskStatus.Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="@AppTaskStatus.InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="@AppTaskStatus.Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + </select> + </div> + <div class="w-40 shrink-0"> + <select @bind="filterPriorityIdInput" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="">@AppLocalization.Text("common.allPriorities", "All Priorities")</option> + <option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option> + <option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option> + <option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option> + </select> + </div> + <button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm shrink-0"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2.5 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm shrink-0"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + </div> + </div> + + <!-- Tasks Table (List View) --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden mt-4"> + <table class="w-full table-fixed"> + <colgroup> + <col class="w-[12%]" /> + <col class="w-[22%]" /> + <col class="w-[11%]" /> + <col class="w-[11%]" /> + <col class="w-[14%]" /> + <col class="w-[16%]" /> + <col class="w-[14%]" /> + <col class="w-[10%]" /> + </colgroup> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.taskCode", "Task Code")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.task", "Task")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.status", "Status")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.priority", "Priority")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.dueDate", "Due Date")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.project", "Project")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.assignee", "Assignee")</th> + <th class="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actions", "Actions")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (PagedTasks.Any()) + { + @foreach (var entry in PagedTasks.Select((task, index) => (task, index))) + { + var task = entry.task; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-left"> + <span class="font-mono text-[11px] font-semibold text-indigo-700">@GetTaskCode(task.Id)</span> + </td> + <td class="px-6 py-4 text-left"> + <a href="/tasks/@task.Id/details" class="text-sm font-semibold text-slate-900 hover:text-violet-600 transition-colors">@Content.GetTaskTitle(task)</a> + @if (!string.IsNullOrEmpty(Content.GetTaskDescription(task))) + { + <p class="mt-0.5 text-xs text-slate-400 truncate max-w-full">@Content.GetTaskDescription(task)</p> + } + </td> + <td class="px-6 py-4 text-left"> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @GetStatusBadge(task.StatusId)"> + @GetStatusLabel(task.StatusId) + </span> + </td> + <td class="px-6 py-4 text-left"> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @GetPriorityBadge(task.PriorityId)"> + @GetPriorityLabel(task.PriorityId) + </span> + </td> + <td class="px-6 py-4 text-sm text-slate-600 text-left whitespace-nowrap">@DisplayFormats.Date(task.DueDate)</td> + <td class="px-6 py-4 text-sm text-slate-500 text-left break-words"> + @{ var proj = projects.FirstOrDefault(p => p.Id == task.ProjectId); } + @(proj != null ? Content.GetProjectName(proj) : $"#{task.ProjectId}") + </td> + <td class="px-6 py-4 text-sm text-slate-500 font-medium text-left"> + @{ var user = allUsers.FirstOrDefault(u => u.Id == task.AssignedTo); } + @if (user != null) + { + <div class="flex items-center gap-2"> + <span class="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-[10px] font-bold"> + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + </span> + <span class="text-xs text-slate-700 font-semibold">@Content.GetUserFullName(user)</span> + </div> + } + else + { + <span class="text-xs text-slate-400 italic">@AppLocalization.Text("common.unassigned", "Unassigned")</span> + } + </td> + <td class="px-6 py-4 text-right"> + <div class="flex items-center justify-end space-x-2"> + @if (canEditTask) + { + <button @onclick="() => OpenEditModal(task)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="@AppLocalization.Text("action.edit", "Edit")"> + <i data-lucide="pencil" class="h-4 w-4"></i> + </button> + } + @if (canDeleteTask) + { + <button @onclick="() => RequestDeleteConfirmation(task.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="@AppLocalization.Text("action.delete", "Delete")"> + <i data-lucide="trash-2" class="h-4 w-4"></i> + </button> + } + </div> + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="8" Icon="check-square" Title="@AppLocalization.Text("task.noTasksYet", "No tasks yet")" Subtitle="@AppLocalization.Text("task.createFirstTask", "Create your first task to get started.")" /> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="TotalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } +</div> + +@* ===== Create / Edit Modal ===== *@ +@if (showModal) +{ + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> + <div class="bg-white rounded-2xl shadow-lg w-full max-w-lg mx-4 overflow-hidden"> + <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100"> + <h3 class="text-lg font-semibold text-slate-900">@(isEditing ? AppLocalization.Text("task.editTask", "Edit Task") : AppLocalization.Text("task.newTask", "New Task"))</h3> + <button @onclick="CloseModal" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"> + <i data-lucide="x" class="h-5 w-5"></i> + </button> + </div> + <div class="p-6 space-y-4"> + <BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formTitleMy))" + EnglishComplete="@(!string.IsNullOrWhiteSpace(formTitle))" + BurmeseAiGenerated="@burmeseGenerated" + EnglishAiGenerated="@englishGenerated" + BurmeseNeedsReview="@burmeseNeedsReview" + EnglishNeedsReview="@englishNeedsReview"> + <BurmeseContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">ခေါင်းစဉ် <span class="text-red-500">*</span></label> + <input @bind="formTitleMy" type="text" placeholder="ခေါင်းစဉ်" class="w-full rounded-lg border @(isTitleError && string.IsNullOrWhiteSpace(formTitle) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formTitleMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveTaskTitleToEnglish" /> + <BilingualTranslationAction SourceText="@formTitleMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitle))" Generated="GenerateTaskEnglishTitle" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">ဖော်ပြချက်</label> + <textarea @bind="formDescriptionMy" rows="3" placeholder="ဖော်ပြချက်" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescriptionMy" ExpectedBurmese="true" MoveToOtherLanguage="MoveTaskDescriptionToEnglish" /> + <BilingualTranslationAction SourceText="@formDescriptionMy" SourceLanguage="my" TargetLanguage="en" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescription))" Generated="GenerateTaskEnglishDescription" /> + </BurmeseContent> + <EnglishContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("field.title", "Title") <span class="text-red-500">*</span></label> + <input @bind="formTitle" type="text" placeholder="@AppLocalization.Text("task.taskTitle", "Task title")" class="w-full rounded-lg border @(isTitleError && string.IsNullOrWhiteSpace(formTitleMy) ? "border-red-500" : "border-slate-200") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + <BilingualFieldWarning Text="@formTitle" ExpectedBurmese="false" MoveToOtherLanguage="MoveTaskTitleToBurmese" /> + <BilingualTranslationAction SourceText="@formTitle" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formTitleMy))" Generated="GenerateTaskBurmeseTitle" /> + <label class="mt-4 block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.description", "Description")</label> + <textarea @bind="formDescription" rows="3" placeholder="@AppLocalization.Text("task.taskDescription", "Task description")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"></textarea> + <BilingualFieldWarning Text="@formDescription" ExpectedBurmese="false" MoveToOtherLanguage="MoveTaskDescriptionToBurmese" /> + <BilingualTranslationAction SourceText="@formDescription" SourceLanguage="en" TargetLanguage="my" TargetHasText="@(!string.IsNullOrWhiteSpace(formDescriptionMy))" Generated="GenerateTaskBurmeseDescription" /> + </EnglishContent> + </BilingualEditorTabs> + <div class="grid grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.project", "Project") <span class="text-red-500">*</span></label> + <select value="@formProjectId" @onchange="OnFormProjectChanged" class="w-full rounded-lg border @(isProjectError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("task.selectProject", "Select project...")</option> + @foreach (var p in projects) + { + <option value="@p.Id">@Content.GetProjectName(p)</option> + } + </select> + @if (isProjectError) + { + <p class="mt-1 text-xs text-red-500">@projectErrorMessage</p> + } + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.status", "Status")</label> + <select @bind="formStatusId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"> + <option value="@AppTaskStatus.Todo">@AppLocalization.StatusLabel(AppTaskStatus.Todo)</option> + <option value="@AppTaskStatus.InProgress">@AppLocalization.StatusLabel(AppTaskStatus.InProgress)</option> + <option value="@AppTaskStatus.Done">@AppLocalization.StatusLabel(AppTaskStatus.Done)</option> + </select> + </div> + </div> + <div class="grid grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.priority", "Priority")</label> + <select @bind="formPriorityId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"> + <option value="@TaskPriority.Low">@AppLocalization.PriorityLabel(TaskPriority.Low)</option> + <option value="@TaskPriority.Medium">@AppLocalization.PriorityLabel(TaskPriority.Medium)</option> + <option value="@TaskPriority.High">@AppLocalization.PriorityLabel(TaskPriority.High)</option> + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.dueDate", "Due Date")</label> + <input @bind="formDueDate" type="date" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + </div> + <div class="grid grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.assignee", "Assignee")</label> + <div class="relative"> + <button type="button" + @onclick="ToggleAssigneeDropdown" + class="flex h-11 w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-3 text-left text-sm text-slate-900 shadow-sm transition-colors hover:border-violet-300 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-900"> + <span class="truncate @(formAssignedTo.HasValue ? "font-medium text-slate-900" : "text-slate-400")">@SelectedAssigneeLabel</span> + <i data-lucide="chevron-down" class="ml-3 h-4 w-4 shrink-0 text-slate-400"></i> + </button> + + @if (isAssigneeDropdownOpen) + { + <div class="absolute bottom-full left-0 z-30 mb-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-lg"> + <div class="flex items-center justify-end"> + <button type="button" + @onclick="CloseAssigneeDropdown" + class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600" + aria-label="@AppLocalization.Text("common.close", "Close")"> + <i data-lucide="x" class="h-4 w-4"></i> + </button> + </div> + <div class="relative"> + <input @bind="assigneeSearchInput" @bind:event="oninput" + @onfocus="EnsureAssigneeDropdownOpen" + type="text" + placeholder="@AppLocalization.Text("common.searchProjectMembers", "Search project members...")" + class="w-full rounded-xl border border-slate-200 bg-white py-2 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" /> + <i data-lucide="search" class="absolute left-3 top-2.5 h-4 w-4 text-slate-400"></i> + </div> + + @if (isLoadingProjectMembers) + { + <div class="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500"> + @AppLocalization.Text("common.loadingProjectMembers", "Loading project members...") + </div> + } + else + { + <div class="mt-3 max-h-56 overflow-y-auto space-y-2 pr-1"> + <button type="button" + @onclick="() => SetFormAssignee(null)" + class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(formAssignedTo.HasValue ? "border-slate-200 bg-white hover:bg-slate-50" : "border-violet-600 bg-violet-50/40")"> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 text-xs font-bold shrink-0"> + <i data-lucide="user-minus" class="h-4 w-4"></i> + </span> + <div class="min-w-0 flex-1"> + <p class="text-sm font-semibold text-slate-900">@AppLocalization.Text("common.unassigned", "Unassigned")</p> + </div> + </button> + + @foreach (var user in FilteredProjectMembers) + { + var isSelected = formAssignedTo == user.Id; + <button type="button" + @onclick="() => SetFormAssignee(user.Id)" + class="flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors @(isSelected ? "border-violet-600 bg-violet-50/40" : "border-slate-100 bg-white hover:border-slate-200 hover:bg-slate-50")"> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-violet-100 text-violet-700 text-xs font-bold shrink-0"> + @(user.FirstName.Length > 0 ? user.FirstName[0].ToString().ToUpper() : "")@(user.LastName.Length > 0 ? user.LastName[0].ToString().ToUpper() : "") + </span> + <div class="min-w-0 flex-1"> + <p class="truncate text-sm font-semibold text-slate-900">@Content.GetUserFullName(user)</p> + <p class="truncate text-xs text-slate-500">@@@user.Username</p> + </div> + </button> + } + + @if (!FilteredProjectMembers.Any()) + { + <div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-center text-xs text-slate-500"> + @AppLocalization.Text("common.noMembersMatchSearch", "No members match the current search.") + </div> + } + </div> + } + </div> + } + </div> + </div> + </div> + + @if (!string.IsNullOrEmpty(errorMessage)) + { + <div class="rounded-lg bg-rose-50 p-3 border border-rose-200"> + <p class="text-xs text-rose-600 font-medium flex items-center gap-1.5"> + <svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> + @errorMessage + </p> + </div> + } + </div> + <div class="flex items-center justify-end space-x-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50"> + <button @onclick="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">@AppLocalization.Text("common.cancel", "Cancel")</button> + <button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors"> + @(isSaving ? AppLocalization.Text("common.saving", "Saving...") : (isEditing ? AppLocalization.Text("action.update", "Update") : AppLocalization.Text("action.create", "Create"))) + </button> + </div> + </div> + </div> +} + +@* ===== Confirm Dialog ===== *@ +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@confirmTitle" + Message="@confirmMessage" + ConfirmText="@confirmButtonText" + Icon="@confirmIcon" + IconBgClass="@confirmIconBg" + IconTextClass="@confirmIconText" + ButtonClass="@confirmButtonClass" + OnConfirm="HandleConfirmAction" + OnCancel="CloseConfirmDialog" /> + +@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<Task>? confirmAction; + private List<TaskDto> taskList = new(); + private List<ProjectDto> projects = new(); + private List<UserDto> allUsers = new(); + private List<UserDto> 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<TaskDto> PagedTasks => taskList; + + // Form fields + private long editingId; + private string formTitle = ""; + private string? formTitleMy; + private string? formDescription; + private string? formDescriptionMy; + private bool burmeseGenerated; + private bool englishGenerated; + private bool burmeseNeedsReview; + private bool englishNeedsReview; + 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; + + private void MoveTaskTitleToEnglish() { formTitle = formTitleMy ?? string.Empty; } + private void MoveTaskTitleToBurmese() { formTitleMy = formTitle; } + private void MoveTaskDescriptionToEnglish() { formDescription = formDescriptionMy; } + private void MoveTaskDescriptionToBurmese() { formDescriptionMy = formDescription; } + private void MarkGenerated(bool english) + { + if (english) { englishGenerated = true; englishNeedsReview = true; } + else { burmeseGenerated = true; burmeseNeedsReview = true; } + } + private Task GenerateTaskEnglishTitle(TranslationResult result) { formTitle = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateTaskBurmeseTitle(TranslationResult result) { formTitleMy = result.TranslatedText; MarkGenerated(false); return Task.CompletedTask; } + private Task GenerateTaskEnglishDescription(TranslationResult result) { formDescription = result.TranslatedText ?? string.Empty; MarkGenerated(true); return Task.CompletedTask; } + private Task GenerateTaskBurmeseDescription(TranslationResult result) { formDescriptionMy = result.TranslatedText; MarkGenerated(false); 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"); + 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<List<ProjectDto>>("Project", Serialization.CaseInsensitive); + var usersTask = client.GetFromJsonAsync<List<UserDto>>("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<PagedResult<TaskDto>?> LoadTasksPageAsync(HttpClient? client = null) + { + client ??= ApiClient.CreateClient(); + var response = await client.GetFromJsonAsync<PagedResult<TaskDto>>(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<string> + { + $"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<TaskDto>($"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<UserDto> FilteredProjectMembers + { + get + { + var result = projectMembers.Where(u => u.IsActive); + + if (!string.IsNullOrWhiteSpace(assigneeSearchInput)) + { + var search = assigneeSearchInput.Trim(); + result = result.Where(u => + Content.GetUserFullName(u).Contains(search, StringComparison.OrdinalIgnoreCase) || + $"{u.FirstName} {u.LastName} {u.FirstNameMy} {u.LastNameMy}".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 AppLocalization.Text("common.unassigned", "Unassigned"); + } + + var selectedUser = projectMembers.FirstOrDefault(u => u.Id == formAssignedTo.Value); + return selectedUser == null + ? AppLocalization.Text("common.selected", "selected") + : Content.GetUserFullName(selectedUser); + } + } + + 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<Result<IEnumerable<UserDto>>>($"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 = ""; + formTitleMy = null; + formDescription = null; + formDescriptionMy = null; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + 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; + formTitleMy = task.TitleMy; + formDescription = task.Description; + formDescriptionMy = task.DescriptionMy; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + 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) && string.IsNullOrWhiteSpace(formTitleMy)) + { + isTitleError = true; + titleErrorMessage = AppLocalization.Text("validation.taskTitleRequired", "Task title is required."); + hasError = true; + } + if (formProjectId == 0) + { + isProjectError = true; + projectErrorMessage = AppLocalization.Text("validation.selectProjectRequired", "Please select a project."); + hasError = true; + } + if (hasError) return; + + if (isEditing) + { + confirmTitle = AppLocalization.Text("task.updateTitle", "Update Task?"); + confirmMessage = AppLocalization.Text("task.updateConfirm", "Are you sure you want to update this task?"); + 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("task.createTitle", "Create Task?"); + confirmMessage = AppLocalization.Text("task.createConfirm", "Are you sure you want to create this task?"); + 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 = SaveTask; + showConfirmDialog = true; + } + + private void RequestDeleteConfirmation(long id) + { + confirmTitle = AppLocalization.Text("task.deleteTitle", "Delete Task?"); + confirmMessage = AppLocalization.Text("task.deleteConfirm", "Are you sure you want to delete this task? 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 = () => 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) && string.IsNullOrWhiteSpace(formTitleMy)) + { + 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, + TitleMy = formTitleMy, + Description = formDescription, + DescriptionMy = formDescriptionMy, + StatusId = formStatusId, + PriorityId = formPriorityId, + DueDate = formDueDate, + AssignedTo = formAssignedTo + }; + var response = await client.PutAsJsonAsync($"Task/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToUpdateTask; + return; + } + } + else + { + var dto = new CreateTaskDto + { + Title = formTitle, + TitleMy = formTitleMy, + Description = formDescription, + DescriptionMy = formDescriptionMy, + ProjectId = formProjectId, + StatusId = formStatusId, + PriorityId = formPriorityId, + DueDate = formDueDate, + AssignedTo = formAssignedTo + }; + var response = await client.PostAsJsonAsync("Task", dto); + var res = await response.Content.ReadFromJsonAsync<Result<TaskDto>>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = res?.ErrorMessage ?? ResultMessages.FailedToCreateTask; + return; + } + } + + CloseModal(); + await LoadTasks(); + successMessage = isEditing ? AppLocalization.Text("task.updated", "Task updated successfully!") : AppLocalization.Text("task.created", "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<Result>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + await JS.InvokeVoidAsync("alert", res?.ErrorMessage ?? AppLocalization.Text("task.failedDelete", "Failed to delete task.")); + return; + } + await LoadTasks(); + if (currentPage > TotalPages && TotalPages > 0) + currentPage = TotalPages; + successMessage = AppLocalization.Text("task.deleted", "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) => AppLocalization.StatusLabel(statusId); + + 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) => AppLocalization.PriorityLabel(priorityId); + +} 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..eaba208096af2c68b09dc48468dcfecd69dd8713 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Pages/Features/Users/Users.razor @@ -0,0 +1,772 @@ +@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 + +<PageTitle>@AppLocalization.PageTitle("users", "Users")</PageTitle> + +<SuccessAlert Message="@successMessage" /> + +<div class="space-y-6"> + <!-- Page Header --> + <div class="flex items-center justify-between"> + <div> + <h2 class="text-3xl font-bold tracking-tight text-slate-900">@AppLocalization.PageTitle("users", "Users")</h2> + <p class="text-slate-500 mt-1">@AppLocalization.PageDescription("users", "Manage people, status, and account details.")</p> + </div> + @if (canCreateUser) + { + <button @onclick="OpenCreateModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="plus" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("common.newUser", "New User") + </button> + } + </div> + + <!-- Search and Filters --> + <div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm mb-4"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex-1 min-w-[240px]"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.searchUsers", "Search Users")</label> + <input @bind="searchInput" @bind:event="oninput" type="text" placeholder="@AppLocalization.Text("common.searchPlaceholder", "Search by name, email, username...")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.role", "Role")</label> + <select @bind="filterRoleIdInput" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="0">@AppLocalization.Text("common.all", "All Roles")</option> + @foreach (var r in roles) + { + <option value="@r.Id">@Content.GetRoleName(r)</option> + } + </select> + </div> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">@AppLocalization.Text("common.status", "Status")</label> + <select @bind="filterStatusInput" class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all"> + <option value="all">@AppLocalization.Text("common.all", "All")</option> + <option value="active">@AppLocalization.Text("status.active", "Active")</option> + <option value="inactive">@AppLocalization.Text("status.inactive", "Inactive")</option> + </select> + </div> + <button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm"> + <i data-lucide="search" class="h-4 w-4 mr-2"></i> + @AppLocalization.Text("action.search", "Search") + </button> + <button @onclick="ResetSearch" title="@AppLocalization.Text("action.reset", "Reset")" aria-label="@AppLocalization.Text("action.reset", "Reset")" class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2.5 text-slate-700 hover:bg-slate-50 transition-colors shadow-sm"> + <i data-lucide="rotate-ccw" class="h-4 w-4 text-slate-500"></i> + </button> + </div> + </div> + + @if (isLoading) + { + <LoadingSpinner Message="@AppLocalization.Text("common.loadingUsers", "Loading users...")" /> + } + else + { + <!-- Users Table --> + <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden"> + <table class="w-full"> + <thead> + <tr class="border-b border-slate-100 bg-slate-50/60"> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.index", "No.")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.user", "User")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.email", "Email")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("field.phone", "Phone")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.role", "Role")</th> + <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.status", "Status")</th> + <th class="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">@AppLocalization.Text("common.actions", "Actions")</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @if (PagedUsers.Any()) + { + @foreach (var entry in PagedUsers.Select((user, index) => (user, index))) + { + var user = entry.user; + <tr class="hover:bg-slate-50/50 transition-colors"> + <td class="px-6 py-4 text-sm font-semibold text-slate-500">@AppLocalization.Number((currentPage - 1) * pageSize + entry.index + 1)</td> + <td class="px-6 py-4"> + <div class="flex items-center space-x-3"> + <span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-700 font-semibold text-xs"> + @(Content.GetUserFullName(user) is var displayName && displayName.Length > 0 ? displayName[0].ToString().ToUpper() : "") + </span> + <div> + <p class="text-sm font-semibold text-slate-900">@Content.GetUserFullName(user)</p> + <p class="text-xs text-slate-400">@@@user.Username</p> + </div> + </div> + </td> + <td class="px-6 py-4 text-sm text-slate-600">@user.Email</td> + <td class="px-6 py-4 text-sm text-slate-500">@(!string.IsNullOrWhiteSpace(user.Phone) ? AppLocalization.LocalizeDigits(user.Phone) : "-")</td> + <td class="px-6 py-4"> + @{ var role = roles.FirstOrDefault(r => r.Id == user.RoleId); } + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700"> + @(role != null ? Content.GetRoleName(role) : $"{AppLocalization.Text("common.role", "Role")} #{user.RoleId}") + </span> + </td> + <td class="px-6 py-4"> + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium @(user.IsActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500")"> + @(user.IsActive ? AppLocalization.Text("status.active", "Active") : AppLocalization.Text("status.inactive", "Inactive")) + </span> + </td> + <td class="px-6 py-4 text-right"> + <div class="flex items-center justify-end space-x-2"> + @if (canEditUser) + { + <button @onclick="() => OpenEditModal(user)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="@AppLocalization.Text("action.edit", "Edit")"> + <i data-lucide="pencil" class="h-4 w-4"></i> + </button> + } + @if (canDeleteUser) + { + <button @onclick="() => RequestDeleteConfirmation(user.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="@AppLocalization.Text("action.delete", "Delete")"> + <i data-lucide="trash-2" class="h-4 w-4"></i> + </button> + } + </div> + </td> + </tr> + } + } + else + { + <EmptyState ColSpan="7" Icon="users" Title="@AppLocalization.Text("common.noResults", "No users found")" Subtitle="@AppLocalization.Text("common.createFirstEntry", "Try adjusting your search or create a new user.")" /> + } + </tbody> + </table> + + <!-- Pagination --> + <Pagination CurrentPage="currentPage" + TotalPages="TotalPages" + PageSize="pageSize" + TotalCount="TotalCount" + OnPageChanged="HandlePageChanged" + OnPageSizeChanged="HandlePageSizeChanged" /> + </div> + } +</div> + +@* ===== Create / Edit User Modal ===== *@ +@if (showModal) +{ + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> + <div class="bg-white rounded-2xl shadow-lg w-full max-w-lg mx-4 overflow-hidden"> + <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100"> + <h3 class="text-lg font-semibold text-slate-900">@(isEditing ? AppLocalization.Text("user.editUser", "Edit User") : AppLocalization.Text("common.newUser", "New User"))</h3> + <button @onclick="CloseModal" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all"> + <i data-lucide="x" class="h-5 w-5"></i> + </button> + </div> + <div class="p-6 space-y-4"> + <BilingualEditorTabs BurmeseComplete="@(!string.IsNullOrWhiteSpace(formFirstNameMy) && !string.IsNullOrWhiteSpace(formLastNameMy))" + EnglishComplete="@(!string.IsNullOrWhiteSpace(formFirstName) && !string.IsNullOrWhiteSpace(formLastName))" + BurmeseAiGenerated="@burmeseGenerated" + EnglishAiGenerated="@englishGenerated" + BurmeseNeedsReview="@burmeseNeedsReview" + EnglishNeedsReview="@englishNeedsReview"> + <BurmeseContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">အမည် <span class="text-red-500">*</span></label> + <div class="grid grid-cols-2 gap-4"> + <input @bind="formFirstNameMy" type="text" placeholder="ရှေ့အမည်" class="w-full rounded-lg border px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + <input @bind="formLastNameMy" type="text" placeholder="နောက်အမည်" class="w-full rounded-lg border px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + </div> + <BilingualFieldWarning Text="@($"{formFirstNameMy} {formLastNameMy}")" ExpectedBurmese="true" MoveToOtherLanguage="MoveUserNameToEnglish" /> + <BilingualTranslationAction SourceText="@($"{formFirstNameMy} {formLastNameMy}")" SourceLanguage="my" TargetLanguage="en" IsName="true" TargetHasText="@(!string.IsNullOrWhiteSpace(formFirstName) || !string.IsNullOrWhiteSpace(formLastName))" Generated="GenerateUserEnglishName" /> + </BurmeseContent> + <EnglishContent> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.name", "Name") <span class="text-red-500">*</span></label> + <div class="grid grid-cols-2 gap-4"> + <input @bind="formFirstName" type="text" placeholder="@AppLocalization.Text("common.firstName", "First name")" class="w-full rounded-lg border px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + <input @bind="formLastName" type="text" placeholder="@AppLocalization.Text("common.lastName", "Last name")" class="w-full rounded-lg border px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-violet-600" /> + </div> + <BilingualFieldWarning Text="@($"{formFirstName} {formLastName}")" ExpectedBurmese="false" MoveToOtherLanguage="MoveUserNameToBurmese" /> + <BilingualTranslationAction SourceText="@($"{formFirstName} {formLastName}")" SourceLanguage="en" TargetLanguage="my" IsName="true" TargetHasText="@(!string.IsNullOrWhiteSpace(formFirstNameMy) || !string.IsNullOrWhiteSpace(formLastNameMy))" Generated="GenerateUserBurmeseName" /> + </EnglishContent> + </BilingualEditorTabs> + @if (isFirstNameError) { <p class="mt-1 text-xs text-red-500">@firstNameErrorMsg</p> } + @if (isLastNameError) { <p class="mt-1 text-xs text-red-500">@lastNameErrorMsg</p> } + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.username", "Username") <span class="text-red-500">*</span></label> + <div class="relative"> + <input @bind="formUsername" type="text" placeholder="@AppLocalization.Text("common.username", "username")" class="w-full rounded-lg border @(isUsernameError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all" /> + @if (isUsernameError) { <svg class="absolute right-3 top-2.5 h-4 w-4 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> } + </div> + @if (isUsernameError) { <p class="mt-1 text-xs text-red-500">@usernameErrorMsg</p> } + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.email", "Email") <span class="text-red-500">*</span></label> + <div class="relative"> + <input @bind="formEmail" type="email" placeholder="user@example.com" class="w-full rounded-lg border @(isEmailError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all" /> + @if (isEmailError) { <svg class="absolute right-3 top-2.5 h-4 w-4 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> } + </div> + @if (isEmailError) { <p class="mt-1 text-xs text-red-500">@emailErrorMsg</p> } + </div> + @if (!isEditing) + { + <div class="relative"> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.password", "Password") <span class="text-red-500">*</span></label> + <input @bind="formPassword" type="@(showPassword ? "text" : "password")" placeholder="@AppLocalization.Text("validation.passwordMinLengthRule", "Min 8 characters (A-Z, a-z, 0-9, special)")" class="w-full rounded-lg border @(isPasswordError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 pr-10 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all" /> + <button type="button" @onclick="TogglePassword" class="absolute right-3 top-[34px] text-slate-400 hover:text-slate-600 focus:outline-none"> + @if (showPassword) + { + <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"></path><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"></path><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"></path><line x1="2" y1="2" x2="22" y2="22"></line></svg> + } + else + { + <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg> + } + </button> + @if (isPasswordError) { <p class="mt-1 text-xs text-red-500">@passwordErrorMsg</p> } + </div> + } + <div class="grid grid-cols-2 gap-4"> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.phone", "Phone")</label> + <input @bind="formPhone" type="text" placeholder="@AppLocalization.Text("common.phoneNumber", "Phone number")" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" /> + </div> + <div> + <label class="block text-sm font-medium text-slate-700 mb-1.5">@AppLocalization.Text("common.role", "Role")</label> + <select @bind="formRoleId" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"> + @foreach (var r in roles) + { + <option value="@r.Id">@Content.GetRoleName(r)</option> + } + </select> + </div> + </div> + @if (isEditing) + { + <div class="flex items-center space-x-3"> + <input @bind="formIsActive" type="checkbox" id="isActiveToggle" class="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900" /> + <label for="isActiveToggle" class="text-sm font-medium text-slate-700">@AppLocalization.Text("status.active", "Active")</label> + </div> + } + + @if (!string.IsNullOrEmpty(errorMessage)) + { + <div class="rounded-lg bg-rose-50 p-3 border border-rose-200"> + <p class="text-xs text-rose-600 font-medium flex items-center gap-1.5"> + <svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> + @errorMessage + </p> + </div> + } + </div> + <div class="flex items-center justify-end space-x-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50"> + <button @onclick="CloseModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">@AppLocalization.Text("common.cancel", "Cancel")</button> + <button @onclick="RequestSaveConfirmation" disabled="@isSaving" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors"> + @(isSaving ? AppLocalization.Text("common.saving", "Saving...") : (isEditing ? AppLocalization.Text("action.update", "Update") : AppLocalization.Text("action.create", "Create"))) + </button> + </div> + </div> + </div> +} + +@* ===== Confirm Dialog ===== *@ +<ConfirmDialog IsVisible="showConfirmDialog" + Title="@confirmTitle" + Message="@confirmMessage" + ConfirmText="@confirmButtonText" + Icon="@confirmIcon" + IconBgClass="@confirmIconBg" + IconTextClass="@confirmIconText" + ButtonClass="@confirmButtonClass" + OnConfirm="HandleConfirmAction" + OnCancel="CloseConfirmDialog" /> + +@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<Task>? confirmAction; + private List<UserDto> users = new(); + private List<RoleDto> 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<UserDto> PagedUsers => users; + + // Form fields + private long editingId; + private string formUsername = ""; + private string formFirstName = ""; + private string formLastName = ""; + private string? formFirstNameMy; + private string? formLastNameMy; + private bool burmeseGenerated; + private bool englishGenerated; + private bool burmeseNeedsReview; + private bool englishNeedsReview; + private string formEmail = ""; + private string formPassword = ""; + private string? formPhone; + private long formRoleId; + private bool formIsActive = true; + + private void MoveUserNameToEnglish() + { + formFirstName = formFirstNameMy ?? string.Empty; + formLastName = formLastNameMy ?? string.Empty; + } + private void MoveUserNameToBurmese() + { + formFirstNameMy = formFirstName; + formLastNameMy = formLastName; + } + private void MarkGenerated(bool english) + { + if (english) { englishGenerated = true; englishNeedsReview = true; } + else { burmeseGenerated = true; burmeseNeedsReview = true; } + } + private void SetGeneratedUserName(string? value, bool english) + { + var parts = (value ?? string.Empty).Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (english) + { + formFirstName = parts.ElementAtOrDefault(0) ?? string.Empty; + formLastName = parts.ElementAtOrDefault(1) ?? string.Empty; + } + else + { + formFirstNameMy = parts.ElementAtOrDefault(0); + formLastNameMy = parts.ElementAtOrDefault(1); + } + MarkGenerated(english); + } + private Task GenerateUserEnglishName(TranslationResult result) { SetGeneratedUserName(result.TranslatedText, true); return Task.CompletedTask; } + private Task GenerateUserBurmeseName(TranslationResult result) { SetGeneratedUserName(result.TranslatedText, false); return Task.CompletedTask; } + + 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<List<RoleDto>>("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<PagedResult<UserDto>?> LoadUsersPageAsync(HttpClient? client = null) + { + client ??= ApiClient.CreateClient(); + return await client.GetFromJsonAsync<PagedResult<UserDto>>(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<string> + { + $"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 = ""; + formFirstNameMy = null; + formLastNameMy = null; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + 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; + formFirstNameMy = user.FirstNameMy; + formLastNameMy = user.LastNameMy; + burmeseGenerated = englishGenerated = burmeseNeedsReview = englishNeedsReview = false; + 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; + + var englishNameComplete = !string.IsNullOrWhiteSpace(formFirstName) && !string.IsNullOrWhiteSpace(formLastName); + var burmeseNameComplete = !string.IsNullOrWhiteSpace(formFirstNameMy) && !string.IsNullOrWhiteSpace(formLastNameMy); + if (!englishNameComplete && !burmeseNameComplete) + { + isFirstNameError = true; + isLastNameError = true; + firstNameErrorMsg = AppLocalization.Text("validation.completeNameRequired", "Enter a complete name in Burmese or English."); + lastNameErrorMsg = firstNameErrorMsg; + 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 = AppLocalization.Text("validation.usernameMinLength", "Username must be at least 3 characters long."); hasError = true; } + else if (!System.Text.RegularExpressions.Regex.IsMatch(formUsername, @"^[a-zA-Z0-9._]+$")) { isUsernameError = true; usernameErrorMsg = AppLocalization.Text("validation.usernameInvalidCharacters", "Username cannot contain spaces, and can only contain letters, numbers, underscores (_), and periods (.)"); 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 = AppLocalization.Text("validation.passwordMinLengthRule", "Password must be at least 8 characters long."); + hasError = true; + } + else if (!System.Text.RegularExpressions.Regex.IsMatch(formPassword, @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$")) + { + isPasswordError = true; + passwordErrorMsg = AppLocalization.Text("validation.passwordComplexityRule", "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."); + 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() + { + var englishNameComplete = !string.IsNullOrWhiteSpace(formFirstName) && !string.IsNullOrWhiteSpace(formLastName); + var burmeseNameComplete = !string.IsNullOrWhiteSpace(formFirstNameMy) && !string.IsNullOrWhiteSpace(formLastNameMy); + if (string.IsNullOrWhiteSpace(formUsername) || (!englishNameComplete && !burmeseNameComplete) || string.IsNullOrWhiteSpace(formEmail)) + { + errorMessage = AppLocalization.Text("validation.fillAllFields", "Please fill in all required fields."); + return; + } + + isSaving = true; + errorMessage = null; + var client = ApiClient.CreateClient(); + + try + { + if (isEditing) + { + var dto = new UpdateUserDto + { + Username = formUsername, + FirstName = formFirstName, + LastName = formLastName, + FirstNameMy = formFirstNameMy, + LastNameMy = formLastNameMy, + Phone = formPhone, + RoleId = formRoleId, + IsActive = formIsActive + }; + var response = await client.PutAsJsonAsync($"User/{editingId}", dto); + var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = AppLocalization.Text("result.userSaveFailed", "Unable to save the user. Please try again."); + return; + } + } + else + { + if (string.IsNullOrWhiteSpace(formPassword)) + { + errorMessage = AppLocalization.Text("validation.passwordRequired", "Password is required."); + isSaving = false; + return; + } + if (formPassword.Length < 8) + { + errorMessage = AppLocalization.Text("validation.passwordMinLengthRule", "Password must be at least 8 characters long."); + isSaving = false; + return; + } + if (!System.Text.RegularExpressions.Regex.IsMatch(formPassword, @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$")) + { + errorMessage = AppLocalization.Text("validation.passwordComplexityRule", "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."); + isSaving = false; + return; + } + var dto = new CreateUserDto + { + Username = formUsername, + FirstName = formFirstName, + LastName = formLastName, + FirstNameMy = formFirstNameMy, + LastNameMy = formLastNameMy, + Email = formEmail, + Password = formPassword, + Phone = formPhone, + RoleId = formRoleId + }; + var response = await client.PostAsJsonAsync("User", dto); + var res = await response.Content.ReadFromJsonAsync<Result<UserDto>>(Serialization.CaseInsensitive); + if (res == null || !res.IsSuccess) + { + errorMessage = AppLocalization.Text("result.userSaveFailed", "Unable to save the user. Please try again."); + return; + } + } + + CloseModal(); + await LoadUsers(); + successMessage = isEditing ? AppLocalization.Text("result.userUpdated", "User updated successfully!") : AppLocalization.Text("result.userCreated", "User created successfully!"); + } + catch (Exception ex) + { + Console.WriteLine($"Save user error: {ex.Message}"); + errorMessage = AppLocalization.Text("result.userSaveFailed", "Unable to save the user. Please try again."); + } + 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<Result>(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}"); + errorMessage = AppLocalization.Text("result.failedToDeleteUser", "Failed to delete user."); + } + } + + 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) +{ + <div class="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm"> + <div class="w-full max-w-sm mx-4 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg animate-in"> + <div class="p-6 text-center"> + <div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full mb-4" style="background-color: @GetIconBg(); color: @GetIconColor();"> + <i data-lucide="@Icon" class="h-7 w-7"></i> + </div> + <h3 class="text-lg font-semibold" style="color: var(--text-primary);">@Title</h3> + <p class="text-sm mt-2" style="color: var(--text-muted);">@Message</p> + </div> + <div class="flex items-center justify-center space-x-3 px-6 py-4 border-t border-slate-100 bg-slate-50/50"> + @if (!HideCancel) + { + <button @onclick="OnCancel" class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors border border-slate-200 bg-white hover:bg-slate-50" style="color: var(--text-muted);"> + @AppLocalization.Text("common.cancel", "Cancel") + </button> + } + <button @onclick="OnConfirm" class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium text-white transition-colors @ButtonClass" style="@ConfirmButtonStyle"> + @ConfirmText + </button> + </div> + </div> + </div> +} + +@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 *@ + +<tr> + <td colspan="@ColSpan" class="px-6 py-12 text-center"> + <div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400"> + <i data-lucide="@Icon" class="h-6 w-6"></i> + </div> + <p class="text-sm font-semibold text-slate-700">@Title</p> + <p class="mx-auto mt-1 max-w-sm text-xs leading-5 text-slate-500">@Subtitle</p> + </td> +</tr> + +@code { + /// <summary>Number of columns to span.</summary> + [Parameter] public int ColSpan { get; set; } = 1; + + /// <summary>Lucide icon name to display.</summary> + [Parameter] public string Icon { get; set; } = "inbox"; + + /// <summary>Primary empty-state message.</summary> + [Parameter] public string Title { get; set; } = AppLocalization.Text("common.noDataYet", "No data yet"); + + /// <summary>Secondary hint text.</summary> + [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<string?>("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 + +<button type="button" + @onclick="ToggleLanguage" + class="control-button px-3" + title="@ButtonTitle" + aria-label="@ButtonTitle"> + <span class="text-xs font-semibold tracking-wide">@ButtonLabel</span> +</button> + +@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 *@ + +<div class="surface-panel flex items-center justify-center py-14"> + <div class="flex items-center space-x-3 text-slate-500"> + <svg class="animate-spin h-5 w-5" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> + </svg> + <span class="text-sm font-medium">@Message</span> + </div> +</div> + +@code { + /// <summary>The text to display next to the spinner.</summary> + [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..53e26194f489827639d5bee19dc5f51a8564f67c --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/NotificationBell.razor @@ -0,0 +1,313 @@ +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@implements IAsyncDisposable +@inject ApiClientService ApiClient +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation +@inject IJSRuntime JS + +<div class="relative"> + <button type="button" + @onclick="ToggleOpenAsync" + class="control-button relative"> + <i data-lucide="bell" class="h-5 w-5"></i> + @if (UnreadCount > 0) + { + <span class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-rose-600 px-1 text-[10px] font-bold text-white"> + @AppLocalization.LocalizeDigits(UnreadCount > 99 ? "99+" : UnreadCount.ToString()) + </span> + } + </button> + + @if (IsOpen) + { + <div class="absolute right-0 z-50 mt-3 w-[380px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg"> + <div class="flex items-center justify-between px-4 py-3 border-b border-slate-100 bg-slate-50/80"> + <div> + <p class="text-sm font-semibold text-slate-900">@AppLocalization.Text("notification.notifications", "Notifications")</p> + <p class="text-xs text-slate-500">@AppLocalization.Number(UnreadCount) @AppLocalization.Text("notification.unread", "unread")</p> + </div> + <div class="flex items-center gap-2"> + @if (Notifications.Any(item => item.IsRead)) + { + <button type="button" class="text-xs font-semibold text-rose-600 hover:text-rose-700" @onclick="RequestCleanupConfirmation"> + @AppLocalization.Text("notification.clearRead", "Clear read") + </button> + } + <button type="button" class="text-xs font-semibold text-violet-600 hover:text-violet-700" @onclick="RefreshAsync"> + @AppLocalization.Text("common.refresh", "Refresh") + </button> + <button type="button" class="text-xs font-semibold text-slate-500 hover:text-slate-700" @onclick="Close"> + @AppLocalization.Text("common.close", "Close") + </button> + </div> + </div> + + <div class="max-h-[26rem] overflow-y-auto"> + @if (IsLoading) + { + <div class="p-4 text-sm text-slate-500">@AppLocalization.Text("notification.loading", "Loading notifications...")</div> + } + else if (Notifications.Count == 0) + { + <div class="p-6 text-center"> + <div class="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-slate-100 text-slate-400"> + <i data-lucide="inbox" class="h-5 w-5"></i> + </div> + <p class="text-sm font-medium text-slate-700">@AppLocalization.Text("notification.noneYet", "No notifications yet")</p> + <p class="text-xs text-slate-500 mt-1">@AppLocalization.Text("notification.emptyState", "Your task alerts will show up here.")</p> + </div> + } + else + { + @foreach (var item in Notifications) + { + <button type="button" + class="w-full border-b border-slate-100 px-4 py-3 text-left transition-colors hover:bg-slate-50 @(item.IsRead ? "bg-white" : "bg-violet-50/70")" + @onclick="() => OpenNotificationAsync(item)"> + <div class="flex items-start gap-3"> + <div class="mt-0.5 flex h-9 w-9 items-center justify-center rounded-full @(item.IsRead ? "bg-slate-100 text-slate-500" : "bg-violet-100 text-violet-700")"> + <i data-lucide="bell-ring" class="h-4 w-4"></i> + </div> + <div class="min-w-0 flex-1"> + <div class="flex items-start justify-between gap-3"> + <p class="text-sm font-semibold text-slate-900 truncate">@GetNotificationTitle(item)</p> + @if (!item.IsRead) + { + <span class="mt-1 h-2.5 w-2.5 rounded-full bg-violet-600 shrink-0"></span> + } + </div> + <p class="mt-1 text-xs text-slate-600 leading-5">@GetNotificationBody(item)</p> + <div class="mt-2 flex items-center justify-between text-[11px] text-slate-400"> + <span>@FormatSender(Content.GetText(item.SenderName, item.SenderNameMy))</span> + <span>@FormatTime(item.CreatedAt)</span> + </div> + </div> + </div> + </button> + } + } + </div> + </div> + } +</div> + +<ConfirmDialog IsVisible="showCleanupConfirmDialog" + Title='@AppLocalization.Text("notification.clearReadTitle", "Clear read notifications?")' + Message='@AppLocalization.Text("notification.clearReadMessage", "This will permanently remove all read notifications from your list.")' + ConfirmText='@AppLocalization.Text("common.clear", "Clear")' + Icon="trash-2" + OnConfirm="ConfirmCleanupAsync" + OnCancel="CloseCleanupConfirmDialog" /> + +@code { + private bool IsOpen; + private bool IsLoading; + private bool showCleanupConfirmDialog; + private int UnreadCount; + private List<NotificationDto> 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<int>("Notification/unread-count"); + var itemsTask = client.GetFromJsonAsync<List<NotificationDto>>("Notification/mine?take=10"); + + await Task.WhenAll(unreadTask, itemsTask); + UnreadCount = unreadTask.Result; + Notifications = (itemsTask.Result ?? new List<NotificationDto>()) + .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) + { + if (string.IsNullOrWhiteSpace(senderName)) + { + return AppLocalization.Text("notification.system", "System"); + } + + return string.Equals(senderName, "System Admin", StringComparison.OrdinalIgnoreCase) + ? AppLocalization.Text("common.systemAdmin", "System Admin") + : senderName; + } + + private static string GetNotificationTitle(NotificationDto item) + { + var title = AppLocalization.IsBurmeseCulture && !string.IsNullOrWhiteSpace(item.TitleMy) + ? item.TitleMy + : item.Title; + + return AppLocalization.LocalizeNotificationTitle(title); + } + + private static string GetNotificationBody(NotificationDto item) + { + var body = AppLocalization.IsBurmeseCulture && !string.IsNullOrWhiteSpace(item.BodyMy) + ? item.BodyMy + : item.Body; + + return AppLocalization.LocalizeNotificationBody(body); + } + + private static string FormatTime(DateTime? createdAt) + { + if (!createdAt.HasValue) + { + return string.Empty; + } + + var value = createdAt.Value.ToLocalTime(); + if (AppLocalization.IsBurmeseCulture) + { + var formatted = $"{AppLocalization.Number(value.Day)} {AppLocalization.MonthName(value, true)}၊ {AppLocalization.LocalizeDigits(value.ToString("HH:mm"))}"; + return AppLocalization.LocalizeDigits(formatted); + } + 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..77292e929a8e441e1d80b54332fafd6aa9846e74 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/Pagination.razor @@ -0,0 +1,122 @@ +@using TaskTrackingSystem.Shared.Localization + +<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 bg-slate-50/60 px-5 py-3"> + <div class="text-xs text-slate-500"> + @if (TotalCount == 0) + { + <span>@AppLocalization.Text("common.noResults", "No results")</span> + } + else + { + var from = (CurrentPage - 1) * PageSize + 1; + var to = Math.Min(CurrentPage * PageSize, TotalCount); + <span> + @AppLocalization.Text("pagination.showing", "Showing") + <span class="font-semibold text-slate-700">@AppLocalization.Number(from)-@AppLocalization.Number(to)</span> + @AppLocalization.Text("pagination.of", "of") + <span class="font-semibold text-slate-700">@AppLocalization.Number(TotalCount)</span> + @AppLocalization.Text("pagination.results", "results") + </span> + } + </div> + + <div class="flex items-center gap-3"> + <div class="flex items-center gap-2 text-xs text-slate-500"> + <span>@AppLocalization.Text("pagination.rowsPerPage", "Rows per page")</span> + <select value="@PageSize" @onchange="HandlePageSizeChange" + class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-violet-600 transition-all"> + <option value="5">@AppLocalization.Number(5)</option> + <option value="10">@AppLocalization.Number(10)</option> + <option value="20">@AppLocalization.Number(20)</option> + <option value="50">@AppLocalization.Number(50)</option> + <option value="100">@AppLocalization.Number(100)</option> + </select> + </div> + + <div class="flex items-center gap-1"> + <button @onclick="() => GoToPage(1)" disabled="@(CurrentPage <= 1)" + class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-40" + title="@AppLocalization.Text("pagination.firstPage", "First page")"> + <i data-lucide="chevrons-left" class="h-3.5 w-3.5"></i> + </button> + <button @onclick="() => GoToPage(CurrentPage - 1)" disabled="@(CurrentPage <= 1)" + class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-40" + title="@AppLocalization.Text("pagination.previousPage", "Previous page")"> + <i data-lucide="chevron-left" class="h-3.5 w-3.5"></i> + </button> + + @foreach (var p in GetVisiblePages()) + { + if (p == -1) + { + <span class="inline-flex h-8 w-8 items-center justify-center text-xs text-slate-400">...</span> + } + else + { + <button @onclick="() => GoToPage(p)" + class="@GetPageButtonClass(p)"> + @AppLocalization.Number(p) + </button> + } + } + + <button @onclick="() => GoToPage(CurrentPage + 1)" disabled="@(CurrentPage >= TotalPages)" + class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-40" + title="@AppLocalization.Text("pagination.nextPage", "Next page")"> + <i data-lucide="chevron-right" class="h-3.5 w-3.5"></i> + </button> + <button @onclick="() => GoToPage(TotalPages)" disabled="@(CurrentPage >= TotalPages)" + class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-40" + title="@AppLocalization.Text("pagination.lastPage", "Last page")"> + <i data-lucide="chevrons-right" class="h-3.5 w-3.5"></i> + </button> + </div> + </div> +</div> + +@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<int> OnPageChanged { get; set; } + [Parameter] public EventCallback<int> 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<int> GetVisiblePages() + { + if (TotalPages <= 7) + { + return Enumerable.Range(1, TotalPages); + } + + var pages = new List<int> { 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)) +{ + <div class="fixed right-4 top-4 z-[100] pointer-events-none sm:right-6 sm:top-6"> + <div class="flex items-center gap-3 rounded-lg border border-teal-500/20 bg-teal-600 px-4 py-3 text-white shadow-lg animate-pulse-once"> + <i data-lucide="check-circle" class="h-5 w-5 shrink-0"></i> + <span class="text-sm font-semibold">@displayMessage</span> + </div> + </div> +} + +@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..9219a88a2204f217a1ab3a6d2b62175b1107329d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Partial/ThemeToggle.razor @@ -0,0 +1,8 @@ +<button type="button" + class="control-button" + data-theme-toggle + title="@AppLocalization.Text("theme.switchToDarkMode", "Switch to dark mode")" + aria-label="@AppLocalization.Text("theme.switchToDarkMode", "Switch to dark mode")" + aria-pressed="false"> + <i data-lucide="moon" class="h-4 w-4"></i> +</button> 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..a3789cabea72ec571417878d78e904dd48a21cc1 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Routes.razor @@ -0,0 +1,24 @@ +<CascadingAuthenticationState> + <Router AppAssembly="typeof(Program).Assembly"> + <Navigating> + <LayoutView Layout="typeof(Layout.MainLayout)"> + <div class="surface-panel mx-auto flex min-h-64 w-full max-w-3xl items-center justify-center p-8 text-sm font-semibold text-slate-500 dark:text-slate-300"> + @AppLocalization.Text("page.loadingPage", "Loading page...") + </div> + </LayoutView> + </Navigating> + <Found Context="routeData"> + <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"> + <NotAuthorized> + <RedirectToLogin /> + </NotAuthorized> + </AuthorizeRouteView> + <FocusOnNavigate RouteData="routeData" Selector="h1" /> + </Found> + <NotFound> + <LayoutView Layout="typeof(Layout.MainLayout)"> + <p class="p-8 text-center text-slate-500 font-sans">@AppLocalization.Text("page.notFound", "Page not found.")</p> + </LayoutView> + </NotFound> + </Router> +</CascadingAuthenticationState> diff --git a/TaskTrackingSystem.WebApp/Components/Shared/BilingualEditorTabs.razor b/TaskTrackingSystem.WebApp/Components/Shared/BilingualEditorTabs.razor new file mode 100644 index 0000000000000000000000000000000000000000..d75a2fbc5a9e205f3f5ccb001c56352516cd4dbd --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/BilingualEditorTabs.razor @@ -0,0 +1,76 @@ +@using TaskTrackingSystem.Shared.Localization + +<div class="space-y-4"> + <div class="flex items-center gap-2 border-b border-slate-200"> + <button type="button" @onclick="() => SelectTab(true)" class="relative px-3 py-2 text-sm font-semibold transition-colors @(showBurmese ? "text-violet-700" : "text-slate-500 hover:text-slate-700")"> + @AppLocalization.Text("common.languageBurmese", "Burmese") + <span class="ml-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium @GetBadgeClass(BurmeseStatus)">@GetStatusLabel(BurmeseStatus)</span> + @if (showBurmese) { <span class="absolute inset-x-0 -bottom-px h-0.5 bg-violet-600"></span> } + </button> + <button type="button" @onclick="() => SelectTab(false)" class="relative px-3 py-2 text-sm font-semibold transition-colors @(!showBurmese ? "text-violet-700" : "text-slate-500 hover:text-slate-700")"> + @AppLocalization.Text("common.languageEnglish", "English") + <span class="ml-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium @GetBadgeClass(EnglishStatus)">@GetStatusLabel(EnglishStatus)</span> + @if (!showBurmese) { <span class="absolute inset-x-0 -bottom-px h-0.5 bg-violet-600"></span> } + </button> + </div> + + @if (BurmeseAiGenerated || EnglishAiGenerated || BurmeseNeedsReview || EnglishNeedsReview) + { + <p class="text-xs text-amber-700"> + @AppLocalization.Text("common.translationReview", "AI-generated content is ready for review. Check it before saving.") + </p> + } + + @if (showBurmese) + { + @BurmeseContent + } + else + { + @EnglishContent + } +</div> + +@code { + [Parameter] public RenderFragment? BurmeseContent { get; set; } + [Parameter] public RenderFragment? EnglishContent { get; set; } + [Parameter] public bool BurmeseComplete { get; set; } + [Parameter] public bool EnglishComplete { get; set; } + [Parameter] public bool BurmeseAiGenerated { get; set; } + [Parameter] public bool EnglishAiGenerated { get; set; } + [Parameter] public bool BurmeseNeedsReview { get; set; } + [Parameter] public bool EnglishNeedsReview { get; set; } + + [Inject] private UiLanguageService UiLanguageService { get; set; } = null!; + + private bool showBurmese = true; + + private string BurmeseStatus => GetStatus(BurmeseComplete, BurmeseAiGenerated, BurmeseNeedsReview); + private string EnglishStatus => GetStatus(EnglishComplete, EnglishAiGenerated, EnglishNeedsReview); + + protected override void OnInitialized() + { + showBurmese = UiLanguageService.CurrentLanguage == AppLanguage.Burmese; + } + + private void SelectTab(bool burmese) => showBurmese = burmese; + + private static string GetStatus(bool complete, bool generated, bool needsReview) => + generated ? "generated" : needsReview ? "review" : complete ? "complete" : "missing"; + + private static string GetStatusLabel(string status) => status switch + { + "complete" => AppLocalization.Text("common.complete", "Complete"), + "generated" => AppLocalization.Text("common.aiGenerated", "AI generated"), + "review" => AppLocalization.Text("common.needsReview", "Needs review"), + _ => AppLocalization.Text("common.missing", "Missing") + }; + + private static string GetBadgeClass(string status) => status switch + { + "complete" => "bg-emerald-50 text-emerald-700", + "generated" => "bg-violet-50 text-violet-700", + "review" => "bg-amber-50 text-amber-700", + _ => "bg-slate-100 text-slate-500" + }; +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/BilingualFieldWarning.razor b/TaskTrackingSystem.WebApp/Components/Shared/BilingualFieldWarning.razor new file mode 100644 index 0000000000000000000000000000000000000000..0481904bae53d84b3dd44c0a8a2922db0faa4f23 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/BilingualFieldWarning.razor @@ -0,0 +1,45 @@ +@using TaskTrackingSystem.Shared.Localization + +@if (ShouldWarn) +{ + <div class="mt-1 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800"> + <div>@Message</div> + <div class="mt-2 flex gap-2"> + <button type="button" @onclick="MoveToOtherLanguageClicked" class="rounded-md bg-amber-600 px-2.5 py-1 font-semibold text-white hover:bg-amber-700">@MoveLabel</button> + <button type="button" @onclick="KeepHereClicked" class="rounded-md border border-amber-300 px-2.5 py-1 font-semibold text-amber-800 hover:bg-amber-100">@AppLocalization.Text("common.keepHere", "Keep here")</button> + </div> + </div> +} + +@code { + [Parameter] public string? Text { get; set; } + [Parameter] public bool ExpectedBurmese { get; set; } + [Parameter] public EventCallback MoveToOtherLanguage { get; set; } + [Parameter] public EventCallback KeepHere { get; set; } + + [Inject] private LanguageDetectionService Detector { get; set; } = null!; + + private bool dismissed; + private LanguageDetectionResult Detection => Detector.Detect(Text); + private bool ShouldWarn => !dismissed && (ExpectedBurmese + ? Detection.Language == DetectedContentLanguage.English + : Detection.Language == DetectedContentLanguage.Burmese); + private string Message => ExpectedBurmese + ? AppLocalization.Text("translation.englishDetected", "This text appears to be English. Move it to the English field?") + : "ဤစာသားသည် မြန်မာဘာသာဖြစ်ပုံရသည်။ မြန်မာအကွက်သို့ ရွှေ့မလား?"; + private string MoveLabel => ExpectedBurmese ? AppLocalization.Text("common.moveToEnglish", "Move to English") : "မြန်မာအကွက်သို့ ရွှေ့ရန်"; + + protected override void OnParametersSet() => dismissed = false; + + private async Task KeepHereClicked() + { + dismissed = true; + await KeepHere.InvokeAsync(); + } + + private async Task MoveToOtherLanguageClicked() + { + dismissed = true; + await MoveToOtherLanguage.InvokeAsync(); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/BilingualTranslationAction.razor b/TaskTrackingSystem.WebApp/Components/Shared/BilingualTranslationAction.razor new file mode 100644 index 0000000000000000000000000000000000000000..4fb8d7d0676db38392a09d4fa369ac9592cb9981 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/BilingualTranslationAction.razor @@ -0,0 +1,58 @@ +@using TaskTrackingSystem.Shared.Localization + +@if (CanGenerate) +{ + <button type="button" @onclick="GenerateAsync" disabled="@isBusy" class="mt-2 inline-flex items-center rounded-md border border-violet-200 bg-violet-50 px-2.5 py-1.5 text-xs font-semibold text-violet-700 hover:bg-violet-100 disabled:opacity-50"> + @if (isBusy) + { + <span>@AppLocalization.Text("common.generating", "Generating...")</span> + } + else + { + <span>@ButtonLabel</span> + } + </button> +} + +@if (!string.IsNullOrWhiteSpace(errorMessage)) +{ + <p class="mt-1 text-xs text-rose-600">@errorMessage</p> +} + +@code { + [Parameter] public string? SourceText { get; set; } + [Parameter] public string SourceLanguage { get; set; } = "my"; + [Parameter] public string TargetLanguage { get; set; } = "en"; + [Parameter] public bool IsName { get; set; } + [Parameter] public bool TargetHasText { get; set; } + [Parameter] public EventCallback<TranslationResult> Generated { get; set; } + + [Inject] private IContentTranslationService TranslationService { get; set; } = null!; + + private bool isBusy; + private string? errorMessage; + + private bool CanGenerate => !TargetHasText && !string.IsNullOrWhiteSpace(SourceText); + private string ButtonLabel => IsName + ? (TargetLanguage == "en" ? AppLocalization.Text("translation.generateEnglishSpelling", "Generate English spelling") : AppLocalization.Text("translation.generateBurmeseName", "မြန်မာအမည် ရေးသားပေးရန်")) + : (TargetLanguage == "en" ? AppLocalization.Text("translation.generateEnglish", "Generate English") : AppLocalization.Text("translation.generateBurmese", "မြန်မာဘာသာ ပြန်ဆိုရန်")); + + private async Task GenerateAsync() + { + if (!CanGenerate) return; + isBusy = true; + errorMessage = null; + var result = IsName + ? await TranslationService.TransliterateNameAsync(SourceText!.Trim(), SourceLanguage, TargetLanguage) + : await TranslationService.TranslateAsync(SourceText!.Trim(), SourceLanguage, TargetLanguage); + isBusy = false; + + if (!result.Success || string.IsNullOrWhiteSpace(result.TranslatedText)) + { + errorMessage = result.ErrorMessage ?? AppLocalization.Text("common.translationNotConfigured", "Translation is not configured."); + return; + } + + await Generated.InvokeAsync(result); + } +} diff --git a/TaskTrackingSystem.WebApp/Components/Shared/Charts/BarChart.razor b/TaskTrackingSystem.WebApp/Components/Shared/Charts/BarChart.razor new file mode 100644 index 0000000000000000000000000000000000000000..fcf7e296d6f4c895a9a9383d9cd11a01d906980a --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/BarChart.razor @@ -0,0 +1,104 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +<div class="@WrapperClass"> + @if (!Groups.Any()) + { + <div class="flex h-full min-h-[220px] items-center justify-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <svg viewBox="0 0 760 280" class="h-full min-h-[260px] w-full overflow-visible"> + @foreach (var tick in YTicks) + { + var y = TickToY(tick); + <g> + <line x1="@PlotLeft" y1="@y" x2="@PlotRight" y2="@y" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="4 4" /> + <text x="42" y="@(y + 4)" text-anchor="end" font-size="12" fill="#64748b">@AppLocalization.Number(tick, "0")</text> + </g> + } + + @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); + + <g> + @for (var j = 0; j < bars.Count; j++) + { + var bar = bars[j]; + var height = ValueToHeight(bar.Value); + <rect x="@(startX + (j * (BarWidth + BarGap)))" + y="@(PlotBottom - height)" + width="@BarWidth" + height="@height" + rx="8" + fill="@bar.Color" /> + } + <text x="@centerX" y="246" text-anchor="middle" font-size="13" fill="#64748b">@group.Label</text> + </g> + } + </svg> + + @if (ShowLegend) + { + <div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-slate-500"> + @foreach (var item in LegendItems) + { + <div class="flex items-center gap-2"> + <span class="h-3 w-3 rounded-full" style="background-color: @item.Color"></span> + @item.Label + </div> + } + </div> + } + } +</div> + +@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<BarChartGroup> Groups { get; set; } = Array.Empty<BarChartGroup>(); + [Parameter] public string WrapperClass { get; set; } = "relative h-[280px]"; + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noChartData", "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<double> 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<ChartDataPoint> 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 @@ +<div class="@CardClass"> + <div class="flex items-start justify-between gap-4"> + <div> + <h3 class="@TitleClass">@Title</h3> + @if (!string.IsNullOrWhiteSpace(Subtitle)) + { + <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">@Subtitle</p> + } + </div> + @if (Actions is not null) + { + <div class="shrink-0"> + @Actions + </div> + } + </div> + + <div class="@BodyClass"> + @ChildContent + </div> +</div> + +@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<ChartDataPoint> 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..3d9db160e621bb23cd921f0001585feed63d76c2 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/DonutChart.razor @@ -0,0 +1,100 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +<div class="@WrapperClass"> + @if (!Items.Any() || Items.Sum(item => item.Value) <= 0) + { + <div class="flex h-56 items-center justify-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <div class="flex justify-center"> + <svg viewBox="0 0 190 190" class="h-56 w-56" role="img" aria-label="Task distribution pie chart"> + @foreach (var slice in Slices) + { + if (slice.IsFullCircle) + { + <circle cx="95" cy="95" r="72" fill="@slice.Color" stroke="var(--surface)" stroke-width="2" /> + } + else + { + <path d="@slice.Path" fill="@slice.Color" stroke="var(--surface)" stroke-width="2" stroke-linejoin="round" /> + } + + } + </svg> + </div> + + @if (ShowLegend) + { + <div class="mt-3 space-y-3"> + @foreach (var item in Items) + { + <div class="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3"> + <div class="flex items-center gap-3"> + <span class="h-3 w-3 rounded-full" style="background-color: @item.Color"></span> + <span class="text-sm text-slate-600">@item.Label</span> + </div> + <span class="text-sm font-semibold text-slate-900">@AppLocalization.Number(item.Value, ValueFormat)</span> + </div> + } + </div> + } + } +</div> + +@code { + [Parameter] public IReadOnlyList<ChartDataPoint> Items { get; set; } = Array.Empty<ChartDataPoint>(); + [Parameter] public bool ShowLegend { get; set; } = true; + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noChartData", "No data available."); + [Parameter] public string ValueFormat { get; set; } = "0"; + [Parameter] public string WrapperClass { get; set; } = string.Empty; + + private IReadOnlyList<Slice> Slices => BuildSlices(); + + private IReadOnlyList<Slice> 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<Slice>(); + + 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..2943bc126700c0a84b3a235f487f0db2838cf3cd --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/GaugeChart.razor @@ -0,0 +1,34 @@ +<div class="@WrapperClass"> + <div class="relative mx-auto flex h-44 w-44 items-center justify-center"> + <svg viewBox="0 0 180 180" class="h-44 w-44 -rotate-90"> + <circle cx="90" cy="90" r="70" fill="none" stroke="#e2e8f0" stroke-width="18" /> + <circle cx="90" cy="90" r="70" fill="none" stroke="@Color" stroke-width="18" stroke-dasharray="@DashArray" stroke-linecap="round" /> + </svg> + <div class="absolute text-center"> + <div class="text-3xl font-bold text-slate-950">@AppLocalization.Number(ClampedValue, "0")%</div> + @if (!string.IsNullOrWhiteSpace(Label)) + { + <div class="mt-1 text-xs font-semibold uppercase tracking-wider text-slate-400">@Label</div> + } + </div> + </div> +</div> + +@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..ee8547a044f677cf1d0a3b9e3290f7ee95f6e27c --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/LineChart.razor @@ -0,0 +1,87 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +<div class="@WrapperClass"> + @if (Points.Count < 2) + { + <div class="flex h-[260px] items-center justify-center text-sm text-slate-400">@EmptyText</div> + } + else + { + <svg viewBox="0 0 760 340" class="h-[340px] w-full"> + <defs> + <linearGradient id="@GradientId" x1="0" x2="0" y1="0" y2="1"> + <stop offset="0%" stop-color="@Color" stop-opacity="0.18" /> + <stop offset="100%" stop-color="@Color" stop-opacity="0" /> + </linearGradient> + </defs> + + @for (var i = 0; i < 5; i++) + { + var y = 60 + (i * 55); + <line x1="48" y1="@y" x2="730" y2="@y" stroke="#e2e8f0" stroke-width="1" /> + } + + @for (var i = 0; i < 6; i++) + { + var x = 60 + (i * 125); + <line x1="@x" y1="40" x2="@x" y2="280" stroke="#e2e8f0" stroke-width="1" /> + } + + <path d="@AreaPath" fill="url(#@GradientId)" /> + <path d="@LinePath" fill="none" stroke="@Color" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" /> + + @foreach (var point in SvgPoints) + { + <circle cx="@point.X" cy="@point.Y" r="5" fill="@Color" stroke="#ddd6fe" stroke-width="3" /> + } + </svg> + + <div class="mt-4 flex items-center justify-between text-xs text-slate-500"> + @foreach (var point in Points) + { + <span class="text-center">@point.Label</span> + } + </div> + } +</div> + +@code { + private readonly string GradientId = $"lineFill{Guid.NewGuid():N}"; + + [Parameter] public IReadOnlyList<LineChartPoint> Points { get; set; } = Array.Empty<LineChartPoint>(); + [Parameter] public string Color { get; set; } = "#2563eb"; + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noChartData", "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..d820511ca42f732d722efe3f61f93aba2e89ac6c --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/ProgressListChart.razor @@ -0,0 +1,47 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +<div class="@WrapperClass"> + @if (!Items.Any()) + { + <div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500"> + @EmptyText + </div> + } + else + { + @foreach (var item in Items) + { + var width = Math.Clamp(item.Value, 0, MaxValue) / MaxValue * 100; + <div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3"> + <div class="flex items-center gap-3"> + @if (!string.IsNullOrWhiteSpace(item.LeadingText)) + { + <div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-white text-sm font-bold text-white shadow-sm" style="background-color: @item.Color;"> + @AppLocalization.LocalizeDigits(item.LeadingText) + </div> + } + <div class="min-w-0 flex-1"> + <div class="flex items-center justify-between gap-3"> + <div class="truncate text-sm font-semibold text-slate-950">@item.Label</div> + <div class="shrink-0 text-sm text-slate-500">@AppLocalization.LocalizeDigits(item.ValueLabel)</div> + </div> + <div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200"> + <div class="h-full rounded-full" style="width: @width%; background-color: @item.Color;"></div> + </div> + @if (!string.IsNullOrWhiteSpace(item.Detail)) + { + <div class="mt-2 text-xs text-slate-500">@AppLocalization.LocalizeDigits(item.Detail)</div> + } + </div> + </div> + </div> + } + } +</div> + +@code { + [Parameter] public IReadOnlyList<ProgressListChartItem> Items { get; set; } = Array.Empty<ProgressListChartItem>(); + [Parameter] public double MaxValue { get; set; } = 100; + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noChartData", "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..4f284d342e5d11c7fd1cd4927fb7600b03d22f86 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/Shared/Charts/TimelineChart.razor @@ -0,0 +1,88 @@ +@using TaskTrackingSystem.WebApp.Components.Shared.Charts + +<div class="@WrapperClass"> + @if (!Items.Any()) + { + <div class="flex items-center justify-center py-16 text-sm text-slate-400">@EmptyText</div> + } + else + { + <div class="min-w-[760px] space-y-3"> + <div class="grid gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400" style="@GridStyle"> + @foreach (var month in Months) + { + <span>@(AppLocalization.IsBurmeseCulture ? $"{AppLocalization.Number(month.Month)}လ" : month.ToString("MMM"))</span> + } + </div> + + @foreach (var item in Bars) + { + <div class="grid items-center gap-3" style="grid-template-columns: 180px minmax(0, 1fr);"> + <div class="truncate text-sm font-semibold text-slate-700">@item.Label</div> + <div class="relative h-8 rounded-full bg-slate-100"> + <div class="absolute inset-y-1 rounded-full" style="left: @item.Left%; width: @item.Width%; background-color: @item.Color;"> + <div class="h-full rounded-full bg-white/25" style="width: @item.CompletionPercentage%;"></div> + </div> + </div> + </div> + } + </div> + } +</div> + +@code { + [Parameter] public IReadOnlyList<TimelineChartItem> Items { get; set; } = Array.Empty<TimelineChartItem>(); + [Parameter] public string EmptyText { get; set; } = AppLocalization.Text("report.noChartData", "No timeline data available."); + [Parameter] public string WrapperClass { get; set; } = "overflow-auto"; + + private IReadOnlyList<DateTime> Months => BuildMonths(); + private string GridStyle => $"grid-template-columns: 180px repeat({Math.Max(Months.Count, 1)}, minmax(56px, 1fr));"; + private IReadOnlyList<TimelineBar> Bars => BuildBars(); + + private IReadOnlyList<DateTime> BuildMonths() + { + if (!Items.Any()) + { + return Array.Empty<DateTime>(); + } + + 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<DateTime>(); + + for (var current = start; current <= end; current = current.AddMonths(1)) + { + months.Add(current); + } + + return months; + } + + private IReadOnlyList<TimelineBar> BuildBars() + { + if (!Months.Any()) + { + return Array.Empty<TimelineBar>(); + } + + 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..337057cf61609c4012e1a68cf53929a53dda2e89 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Components/_Imports.razor @@ -0,0 +1,41 @@ +@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.Localization +@inject ILocalizedContentService Content +@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.Shared +@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/ApiContentTranslationService.cs b/TaskTrackingSystem.WebApp/Localization/ApiContentTranslationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..d8e26120001f61affd720070b8a40d745b7972a2 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Localization/ApiContentTranslationService.cs @@ -0,0 +1,37 @@ +using System.Net.Http.Json; +using TaskTrackingSystem.Shared.Localization; + +namespace TaskTrackingSystem.WebApp.Localization; + +/// <summary> +/// Client-side proxy only. Provider credentials stay inside the Web API. +/// </summary> +public sealed class ApiContentTranslationService : IContentTranslationService +{ + private readonly ApiClientService apiClient; + + public ApiContentTranslationService(ApiClientService apiClient) + { + this.apiClient = apiClient; + } + + public Task<TranslationResult> TranslateAsync(string sourceText, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => + GenerateAsync(new TranslationRequest(sourceText, sourceLanguage, targetLanguage), cancellationToken); + + public Task<TranslationResult> TransliterateNameAsync(string sourceName, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => + GenerateAsync(new TranslationRequest(sourceName, sourceLanguage, targetLanguage, true), cancellationToken); + + private async Task<TranslationResult> GenerateAsync(TranslationRequest request, CancellationToken cancellationToken) + { + try + { + using var response = await apiClient.CreateClient().PostAsJsonAsync("Translation/generate", request, cancellationToken); + var result = await response.Content.ReadFromJsonAsync<TranslationResult>(cancellationToken: cancellationToken); + return result ?? new TranslationResult(false, null, $"Translation request failed ({(int)response.StatusCode}).", "api", false); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return new TranslationResult(false, null, ex.Message, "api", false); + } + } +} diff --git a/TaskTrackingSystem.WebApp/Localization/LocalizedContentService.cs b/TaskTrackingSystem.WebApp/Localization/LocalizedContentService.cs new file mode 100644 index 0000000000000000000000000000000000000000..88dd2a5252f03d560c46d6b22ef513a611f5bb46 --- /dev/null +++ b/TaskTrackingSystem.WebApp/Localization/LocalizedContentService.cs @@ -0,0 +1,152 @@ +using TaskTrackingSystem.Shared.Models.Issue; +using TaskTrackingSystem.Shared.Localization; +using TaskTrackingSystem.Shared.Models.Menu; +using TaskTrackingSystem.Shared.Models.Project; +using TaskTrackingSystem.Shared.Models.Role; +using TaskTrackingSystem.Shared.Models.Task; +using TaskTrackingSystem.Shared.Models.User; + +namespace TaskTrackingSystem.WebApp.Localization; + +public interface ILocalizedContentService +{ + string GetText(string? english, string? burmese); + + string GetFullName( + string? firstNameEnglish, + string? lastNameEnglish, + string? firstNameBurmese, + string? lastNameBurmese); + + string GetProjectName(ProjectDto project); + string GetProjectDescription(ProjectDto project); + string GetTaskTitle(TaskDto task); + string GetTaskDescription(TaskDto task); + string GetIssueTitle(IssueDto issue); + string GetIssueDescription(IssueDto issue); + string GetMenuName(MenuDto menu); + string GetMenuName(AccessMenuDto menu); + string GetRoleName(RoleDto role); + string GetPermissionName(string? actionNameEnglish, string? actionNameBurmese); + string GetUserFullName(UserDto user); +} + +public sealed class LocalizedContentService : ILocalizedContentService +{ + private readonly UiLanguageService _uiLanguageService; + private readonly LanguageDetectionService _languageDetection = new(); + private static readonly IReadOnlyDictionary<string, string> KnownBurmeseNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["Kyaw Kyaw Aung"] = "ကျော်ကျော် အောင်", + ["Min Min Htun"] = "မင်းမင်း ထွန်း", + ["Mg Mg Naing"] = "မောင်မောင် နိုင်", + ["Aung Aung Oo"] = "အောင်အောင် ဦး", + ["Wai Yan Tun"] = "ဝေယံ ထွန်း", + ["Thura Ko"] = "သူရ ကို", + ["Su Su Win"] = "စုစု ဝင်း", + ["Nandar Hlaing"] = "နန္ဒာ လှိုင်", + ["Phyo Phyo Aye"] = "ဖြိုးဖြိုး အေး", + ["Zaw Zaw Lin"] = "ဇော်ဇော် လင်း", + ["Hla Hla Khin"] = "လှလှ ခင်", + ["Nwe Nwe Aye"] = "နွေနွေ အေး", + ["Ei Ei Mon"] = "အိအိ မွန်", + ["Thandar Win"] = "သန္တာ ဝင်း", + ["Khin Khin Tun"] = "ခင်ခင် ထွန်း", + ["Su Mon Hlaing"] = "စုမွန် လှိုင်", + ["Mon Mon Aung"] = "မွန်မွန် အောင်", + ["Yee Yee Lwin"] = "ရီရီ လွင်", + ["Pyae Pyae Naing"] = "ပြည့်ပြည့် နိုင်", + ["May Zaw"] = "မေ ဇော်", + ["Htet Htet Oo"] = "ထက်ထက် ဦး", + ["Aye Aye Khaing"] = "အေးအေး ခိုင်", + ["Chaw Chaw Aung"] = "ချောချော အောင်", + ["Mya Mya Myint"] = "မြမြ မြင့်", + ["Lwin Lwin Htun"] = "လွင်လွင် ထွန်း", + ["Phyu Phyu Soe"] = "ဖြူဖြူ စိုး", + ["Myat Myat Aung"] = "မြတ်မြတ် အောင်", + ["Nyein Nyein Win"] = "ငြိမ်းငြိမ်း ဝင်း", + ["Kyu Kyu Hlaing"] = "ကြူကြူ လှိုင်", + ["San San Aye"] = "စန်းစန်း အေး", + ["Si Si Ko"] = "စီစီ ကို", + ["Moe Moe Thant"] = "မိုးမိုး သန့်", + ["Yu Yu Naing"] = "ယုယု နိုင်", + ["Pan Pan Wai"] = "ပန်းပန်း ဝေ", + ["Khaing Khine"] = "ခိုင် ခိုင်", + ["Thiri Aung"] = "သီရိ အောင်", + ["Hnin Aye"] = "နှင်း အေး", + ["Wint Oo"] = "ဝင့် ဦး", + ["Chan Moe"] = "ချမ်း မိုး", + ["Aye Mon"] = "အေးမွန်", + ["Htoo Aung Lwin"] = "ထူးအောင် လွင်", + ["Thin Htet San"] = "သင်းထက် စံ", + ["Maung Aye"] = "မောင် အေး" + }; + + public LocalizedContentService(UiLanguageService uiLanguageService) + { + _uiLanguageService = uiLanguageService; + } + + public string GetText(string? english, string? burmese) + { + if (_uiLanguageService.CurrentLanguage == AppLanguage.Burmese) + { + return !string.IsNullOrWhiteSpace(burmese) ? burmese : english ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(english)) + { + return string.Empty; + } + + return _languageDetection.AppearsPredominantlyBurmese(english) + ? string.Empty + : english; + } + + public string GetFullName(string? firstNameEnglish, string? lastNameEnglish, string? firstNameBurmese, string? lastNameBurmese) + { + var english = JoinName(firstNameEnglish, lastNameEnglish); + var burmese = JoinName(firstNameBurmese, lastNameBurmese); + + // These are standard system display labels, not personal names. + if (string.Equals(english, "System Admin", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.Text("common.systemAdmin", "System Admin"); + } + + if (string.Equals(english, "User", StringComparison.OrdinalIgnoreCase)) + { + return AppLocalization.Text("common.user", "User"); + } + + if (_uiLanguageService.CurrentLanguage == AppLanguage.Burmese + && string.IsNullOrWhiteSpace(burmese) + && KnownBurmeseNames.TryGetValue(english, out var knownBurmeseName)) + { + return knownBurmeseName; + } + + return GetText(english, burmese); + } + + public string GetProjectName(ProjectDto project) => WithFallback(GetText(project.Name, project.NameMy), AppLocalization.Text("project.untitledProject", "Untitled project")); + public string GetProjectDescription(ProjectDto project) => GetText(project.Description, project.DescriptionMy); + public string GetTaskTitle(TaskDto task) => WithFallback(GetText(task.Title, task.TitleMy), AppLocalization.Text("task.untitledTask", "Untitled task")); + public string GetTaskDescription(TaskDto task) => GetText(task.Description, task.DescriptionMy); + public string GetIssueTitle(IssueDto issue) => WithFallback(GetText(issue.Title, issue.TitleMy), AppLocalization.Text("issue.untitledIssue", "Untitled issue")); + public string GetIssueDescription(IssueDto issue) => GetText(issue.Description, issue.DescriptionMy); + public string GetMenuName(MenuDto menu) => GetText(menu.MenuName, menu.MenuNameMy); + public string GetMenuName(AccessMenuDto menu) => GetText(menu.MenuName, menu.MenuNameMy); + public string GetRoleName(RoleDto role) => GetText(role.Name, role.NameMy); + public string GetPermissionName(string? actionNameEnglish, string? actionNameBurmese) => GetText(actionNameEnglish, actionNameBurmese); + public string GetUserFullName(UserDto user) => GetFullName(user.FirstName, user.LastName, user.FirstNameMy, user.LastNameMy); + + private static string JoinName(string? firstName, string? lastName) + { + return string.Join(" ", new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!.Trim())); + } + + private static string WithFallback(string text, string fallback) => + string.IsNullOrWhiteSpace(text) ? fallback : text; +} diff --git a/TaskTrackingSystem.WebApp/Localization/UiLanguageService.cs b/TaskTrackingSystem.WebApp/Localization/UiLanguageService.cs new file mode 100644 index 0000000000000000000000000000000000000000..1e63517ad4a8db11f25438e19430f2865a9e905d --- /dev/null +++ b/TaskTrackingSystem.WebApp/Localization/UiLanguageService.cs @@ -0,0 +1,123 @@ +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.Burmese; + + 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<string?>("languagePreference.init", StorageKey); + SetLanguageFromCode(code); + } + + public async Task ToggleAsync() + { + var code = await _js.InvokeAsync<string?>("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, "en", StringComparison.OrdinalIgnoreCase) || + string.Equals(code, "en-US", StringComparison.OrdinalIgnoreCase) + ? AppLanguage.English + : AppLanguage.Burmese; + + public string T(string key, string fallback = "") => AppLocalization.Text(key, 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<string> PublicRouteSegments = new(StringComparer.OrdinalIgnoreCase) + { + string.Empty, + "login", + "register", + "error", + "reset-password" + }; + + private readonly ApiClientService _apiClient; + private readonly UserSessionState _sessionState; + private Task<List<MenuDto>>? _loadingAccessItems; + private Task<HashSet<string>>? _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<MenuDto> 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<List<MenuDto>> GetUserMenusAsync(ClaimsPrincipal user) + { + if (user.Identity?.IsAuthenticated != true) + { + return Task.FromResult(new List<MenuDto>()); + } + + var roleKey = GetRoleCacheKey(user); + + if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null) + { + return Task.FromResult(_sessionState.CachedAccessItems); + } + + return LoadAccessItemsAsync(user, roleKey); + } + + public async Task<bool> HasAccessCodeAsync(ClaimsPrincipal user, string accessCode) + { + if (string.IsNullOrWhiteSpace(accessCode)) + { + return false; + } + + var codes = await GetCurrentAccessCodesAsync(user); + return codes.Contains(accessCode.Trim()); + } + + public Task<HashSet<string>> GetCurrentAccessCodesAsync(ClaimsPrincipal user) + { + if (user.Identity?.IsAuthenticated != true) + { + return Task.FromResult(new HashSet<string>(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); + } + + /// <summary> + /// 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). + /// </summary> + private async Task<List<MenuDto>> 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<List<MenuDto>> LoadAccessItemsAsync(ClaimsPrincipal user, string roleKey) + { + if (_loadingAccessItems is { IsCompleted: false }) + { + return _loadingAccessItems; + } + + _loadingAccessItems = FetchAccessItemsFromApiAsync(user, roleKey); + return _loadingAccessItems; + } + + private async Task<HashSet<string>> 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<string>(StringComparer.OrdinalIgnoreCase); + } + + private Task<HashSet<string>> LoadAccessCodesAsync(ClaimsPrincipal user, string roleKey) + { + if (_loadingAccessCodes is { IsCompleted: false }) + { + return _loadingAccessCodes; + } + + _loadingAccessCodes = FetchAccessCodesFromApiAsync(user, roleKey); + return _loadingAccessCodes; + } + + private async Task<HashSet<string>> 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<string>(StringComparer.OrdinalIgnoreCase); + } + + var codes = await codesResponse.Content.ReadFromJsonAsync<List<string>>(cancellationToken: cts.Token) + ?? new List<string>(); + + 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<string>(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<string>(StringComparer.OrdinalIgnoreCase); + _sessionState.CachedAccessCodes = fallback; + _sessionState.CachedAccessRoleId = roleKey; + return fallback; + } + finally + { + _loadingAccessCodes = null; + } + } + + private async Task<List<MenuDto>> 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<MenuDto>(); + } + + var accessItems = await menuResponse.Content.ReadFromJsonAsync<List<MenuDto>>(cancellationToken: cts.Token) + ?? new List<MenuDto>(); + + 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<MenuDto> 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<MenuDto> BuildFallbackMenus(ClaimsPrincipal user) + { + return new List<MenuDto>(); + } + + 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..731496410fa5cddcf6f730df8cf95d0654dc9dad --- /dev/null +++ b/TaskTrackingSystem.WebApp/Program.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using Microsoft.AspNetCore.Authentication.Cookies; +using TaskTrackingSystem.WebApp.Localization; +using TaskTrackingSystem.Shared.Localization; +using TaskTrackingSystem.WebApp; +using TaskTrackingSystem.WebApp.Components; + +var defaultCulture = CultureInfo.GetCultureInfo("my-MM"); +CultureInfo.DefaultThreadCurrentCulture = defaultCulture; +CultureInfo.DefaultThreadCurrentUICulture = defaultCulture; + +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<UserSessionState>(); +builder.Services.AddScoped<ApiClientService>(); +builder.Services.AddScoped<MenuAuthorizationService>(); +builder.Services.AddScoped<UiLanguageService>(); +builder.Services.AddScoped<ILocalizedContentService, LocalizedContentService>(); +builder.Services.AddScoped<TaskTrackingSystem.Shared.Localization.IContentTranslationService, ApiContentTranslationService>(); +builder.Services.AddScoped<TaskTrackingSystem.Shared.Localization.LanguageDetectionService>(); + +// 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<Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider, CustomAuthenticationStateProvider>(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + var catalog = AppLocalization.ValidateCatalogs(); + if (catalog.MissingInEnglish.Count > 0 || catalog.MissingInBurmese.Count > 0 || catalog.DuplicateKeys.Count > 0) + { + app.Logger.LogWarning( + "Localization catalog validation: missing English={MissingEnglish}; missing Burmese={MissingBurmese}; duplicate={Duplicate}", + string.Join(", ", catalog.MissingInEnglish), + string.Join(", ", catalog.MissingInBurmese), + string.Join(", ", catalog.DuplicateKeys)); + } +} + +// 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<App>() + .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..580616f41cafdea301e0d95e40799ec98d6b9d9e --- /dev/null +++ b/TaskTrackingSystem.WebApp/TaskTrackingSystem.WebApp.csproj @@ -0,0 +1,29 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <UseAppHost>false</UseAppHost> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.*" /> + <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.*" /> + </ItemGroup> + + <ItemGroup> + <Folder Include="Components\Partial\" /> + </ItemGroup> + + <ItemGroup> + <Compile Remove="artifacts\**" /> + <EmbeddedResource Remove="artifacts\**" /> + <None Remove="artifacts\**" /> + </ItemGroup> + +</Project> 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<MenuDto>? CachedAccessItems { get; set; } + public HashSet<string>? CachedAccessCodes { get; set; } + + /// <summary> + /// 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. + /// </summary> + 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..cf0ae46592f1953246389ffde57cb7a280645f00 --- /dev/null +++ b/TaskTrackingSystem.WebApp/appsettings.json @@ -0,0 +1,13 @@ +{ + "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..651ca6b604449b18663f1b1e7dd0d1214f65198f --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/app.css @@ -0,0 +1,1823 @@ +: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; +} + +.app-sidebar-user-card { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); +} + +.app-sidebar-user-card:hover { + background: rgba(255, 255, 255, 0.08); +} + +.app-sidebar-avatar { + background: rgba(255, 255, 255, 0.1); + color: #FFFFFF; +} + +.app-sidebar-username { + color: #FFFFFF; +} + +.app-sidebar-signout { + color: #CBD5E1; +} + +html:not(.dark) .app-sidebar-user-card { + 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-card:hover { + background: rgba(255, 255, 255, 0.9) !important; +} + +html:not(.dark) .app-sidebar-avatar { + background: #D8E9E5 !important; + color: #0F4F49 !important; +} + +html:not(.dark) .app-sidebar-username { + color: #17211F !important; +} + +html:not(.dark) .app-sidebar-signout { + color: #52635E !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, `<h1>`-`<h6>` 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 `<p>`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 `<td>` 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-`<button>` buttons\n//\n// Details at https://github.com/twbs/bootstrap/pull/30562\n[role=\"button\"] {\n cursor: pointer;\n}\n\nselect {\n // Remove the inheritance of word-wrap in Safari.\n // See https://github.com/twbs/bootstrap/issues/24990\n word-wrap: normal;\n\n // Undo the opacity change from Chrome\n &:disabled {\n opacity: 1;\n }\n}\n\n// Remove the dropdown arrow in Chrome from inputs built with datalists.\n// See https://stackoverflow.com/a/54997118\n\n[list]::-webkit-calendar-picker-indicator {\n display: none;\n}\n\n// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n// controls in Android 4.\n// 2. Correct the inability to style clickable types in iOS and Safari.\n// 3. Opinionated: add \"hand\" cursor to non-disabled button elements.\n\nbutton,\n[type=\"button\"], // 1\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button; // 2\n\n @if $enable-button-pointers {\n &:not(:disabled) {\n cursor: pointer; // 3\n }\n }\n}\n\n// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\n// 1. Textareas should really only resize vertically so they don't break their (horizontal) containers.\n\ntextarea {\n resize: vertical; // 1\n}\n\n// 1. Browsers set a default `min-width: min-content;` on fieldsets,\n// unlike e.g. `<div>`s, which have `min-width: 0;` by default.\n// So we reset that to ensure fieldsets behave more like a standard block element.\n// See https://github.com/twbs/bootstrap/issues/12359\n// and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements\n// 2. Reset the default outline behavior of fieldsets so they don't affect page layout.\n\nfieldset {\n min-width: 0; // 1\n padding: 0; // 2\n margin: 0; // 2\n border: 0; // 2\n}\n\n// 1. By using `float: left`, the legend will behave like a block element.\n// This way the border of a fieldset wraps around the legend if present.\n// 2. Fix wrapping bug.\n// See https://github.com/twbs/bootstrap/issues/29712\n\nlegend {\n float: left; // 1\n width: 100%;\n padding: 0;\n margin-bottom: $legend-margin-bottom;\n @include font-size($legend-font-size);\n font-weight: $legend-font-weight;\n line-height: inherit;\n\n + * {\n clear: left; // 2\n }\n}\n\n// Fix height of inputs with a type of datetime-local, date, month, week, or time\n// See https://github.com/twbs/bootstrap/issues/18842\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n// 1. Correct the outline style in Safari.\n// 2. This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n\n[type=\"search\"] {\n outline-offset: -2px; // 1\n -webkit-appearance: textfield; // 2\n}\n\n// 1. A few input types should stay LTR\n// See https://rtlstyling.com/posts/rtl-styling#form-inputs\n// 2. RTL only output\n// See https://rtlcss.com/learn/usage-guide/control-directives/#raw\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n\n// Remove the inner padding in Chrome and Safari on macOS.\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n// Remove padding around color pickers in webkit browsers\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n\n// Inherit font family and line height for file input buttons\n\n::file-selector-button {\n font: inherit;\n}\n\n// 1. Change font properties to `inherit`\n// 2. Correct the inability to style clickable types in iOS and Safari.\n\n::-webkit-file-upload-button {\n font: inherit; // 1\n -webkit-appearance: button; // 2\n}\n\n// Correct element displays\n\noutput {\n display: inline-block;\n}\n\n// Remove border from iframe\n\niframe {\n border: 0;\n}\n\n// Summary\n//\n// 1. Add the correct display in all browsers\n\nsummary {\n display: list-item; // 1\n cursor: pointer;\n}\n\n\n// Progress\n//\n// Add the correct vertical alignment in Chrome, Firefox, and Opera.\n\nprogress {\n vertical-align: baseline;\n}\n\n\n// Hidden attribute\n//\n// Always hide an element with the `hidden` HTML attribute.\n\n[hidden] {\n display: none !important;\n}\n","@charset \"UTF-8\";\n/*!\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:root {\n --bs-blue: #0d6efd;\n --bs-indigo: #6610f2;\n --bs-purple: #6f42c1;\n --bs-pink: #d63384;\n --bs-red: #dc3545;\n --bs-orange: #fd7e14;\n --bs-yellow: #ffc107;\n --bs-green: #198754;\n --bs-teal: #20c997;\n --bs-cyan: #0dcaf0;\n --bs-white: #fff;\n --bs-gray: #6c757d;\n --bs-gray-dark: #343a40;\n --bs-gray-100: #f8f9fa;\n --bs-gray-200: #e9ecef;\n --bs-gray-300: #dee2e6;\n --bs-gray-400: #ced4da;\n --bs-gray-500: #adb5bd;\n --bs-gray-600: #6c757d;\n --bs-gray-700: #495057;\n --bs-gray-800: #343a40;\n --bs-gray-900: #212529;\n --bs-primary: #0d6efd;\n --bs-secondary: #6c757d;\n --bs-success: #198754;\n --bs-info: #0dcaf0;\n --bs-warning: #ffc107;\n --bs-danger: #dc3545;\n --bs-light: #f8f9fa;\n --bs-dark: #212529;\n --bs-primary-rgb: 13, 110, 253;\n --bs-secondary-rgb: 108, 117, 125;\n --bs-success-rgb: 25, 135, 84;\n --bs-info-rgb: 13, 202, 240;\n --bs-warning-rgb: 255, 193, 7;\n --bs-danger-rgb: 220, 53, 69;\n --bs-light-rgb: 248, 249, 250;\n --bs-dark-rgb: 33, 37, 41;\n --bs-white-rgb: 255, 255, 255;\n --bs-black-rgb: 0, 0, 0;\n --bs-body-rgb: 33, 37, 41;\n --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\";\n --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));\n --bs-body-font-family: var(--bs-font-sans-serif);\n --bs-body-font-size: 1rem;\n --bs-body-font-weight: 400;\n --bs-body-line-height: 1.5;\n --bs-body-color: #212529;\n --bs-body-bg: #fff;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n :root {\n scroll-behavior: smooth;\n }\n}\n\nbody {\n margin: 0;\n font-family: var(--bs-body-font-family);\n font-size: var(--bs-body-font-size);\n font-weight: var(--bs-body-font-weight);\n line-height: var(--bs-body-line-height);\n color: var(--bs-body-color);\n text-align: var(--bs-body-text-align);\n background-color: var(--bs-body-bg);\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\nhr {\n margin: 1rem 0;\n color: inherit;\n background-color: currentColor;\n border: 0;\n opacity: 0.25;\n}\n\nhr:not([size]) {\n height: 1px;\n}\n\nh6, .h6, h5, .h5, h4, .h4, h3, .h3, h2, .h2, h1, .h1 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n}\n\nh1, .h1 {\n font-size: calc(1.375rem + 1.5vw);\n}\n@media (min-width: 1200px) {\n h1, .h1 {\n font-size: 2.5rem;\n }\n}\n\nh2, .h2 {\n font-size: calc(1.325rem + 0.9vw);\n}\n@media (min-width: 1200px) {\n h2, .h2 {\n font-size: 2rem;\n }\n}\n\nh3, .h3 {\n font-size: calc(1.3rem + 0.6vw);\n}\n@media (min-width: 1200px) {\n h3, .h3 {\n font-size: 1.75rem;\n }\n}\n\nh4, .h4 {\n font-size: calc(1.275rem + 0.3vw);\n}\n@media (min-width: 1200px) {\n h4, .h4 {\n font-size: 1.5rem;\n }\n}\n\nh5, .h5 {\n font-size: 1.25rem;\n}\n\nh6, .h6 {\n font-size: 1rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title],\nabbr[data-bs-original-title] {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n cursor: help;\n -webkit-text-decoration-skip-ink: none;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\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: 700;\n}\n\ndd {\n margin-bottom: 0.5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall, .small {\n font-size: 0.875em;\n}\n\nmark, .mark {\n padding: 0.2em;\n background-color: #fcf8e3;\n}\n\nsub,\nsup {\n position: relative;\n font-size: 0.75em;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\na {\n color: #0d6efd;\n text-decoration: underline;\n}\na:hover {\n color: #0a58ca;\n}\n\na:not([href]):not([class]), a:not([href]):not([class]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: var(--bs-font-monospace);\n font-size: 1em;\n direction: ltr /* rtl:ignore */;\n unicode-bidi: bidi-override;\n}\n\npre {\n display: block;\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n font-size: 0.875em;\n}\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\ncode {\n font-size: 0.875em;\n color: #d63384;\n word-wrap: break-word;\n}\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.2rem 0.4rem;\n font-size: 0.875em;\n color: #fff;\n background-color: #212529;\n border-radius: 0.2rem;\n}\nkbd kbd {\n padding: 0;\n font-size: 1em;\n font-weight: 700;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: #6c757d;\n text-align: left;\n}\n\nth {\n text-align: inherit;\n text-align: -webkit-match-parent;\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\nlabel {\n display: inline-block;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\n[role=button] {\n cursor: pointer;\n}\n\nselect {\n word-wrap: normal;\n}\nselect:disabled {\n opacity: 1;\n}\n\n[list]::-webkit-calendar-picker-indicator {\n display: none;\n}\n\nbutton,\n[type=button],\n[type=reset],\n[type=submit] {\n -webkit-appearance: button;\n}\nbutton:not(:disabled),\n[type=button]:not(:disabled),\n[type=reset]:not(:disabled),\n[type=submit]:not(:disabled) {\n cursor: pointer;\n}\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ntextarea {\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n float: left;\n width: 100%;\n padding: 0;\n margin-bottom: 0.5rem;\n font-size: calc(1.275rem + 0.3vw);\n line-height: inherit;\n}\n@media (min-width: 1200px) {\n legend {\n font-size: 1.5rem;\n }\n}\nlegend + * {\n clear: left;\n}\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n[type=search] {\n outline-offset: -2px;\n -webkit-appearance: textfield;\n}\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n::file-selector-button {\n font: inherit;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\niframe {\n border: 0;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[hidden] {\n display: none !important;\n}\n\n.lead {\n font-size: 1.25rem;\n font-weight: 300;\n}\n\n.display-1 {\n font-size: calc(1.625rem + 4.5vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-1 {\n font-size: 5rem;\n }\n}\n\n.display-2 {\n font-size: calc(1.575rem + 3.9vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-2 {\n font-size: 4.5rem;\n }\n}\n\n.display-3 {\n font-size: calc(1.525rem + 3.3vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-3 {\n font-size: 4rem;\n }\n}\n\n.display-4 {\n font-size: calc(1.475rem + 2.7vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-4 {\n font-size: 3.5rem;\n }\n}\n\n.display-5 {\n font-size: calc(1.425rem + 2.1vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-5 {\n font-size: 3rem;\n }\n}\n\n.display-6 {\n font-size: calc(1.375rem + 1.5vw);\n font-weight: 300;\n line-height: 1.2;\n}\n@media (min-width: 1200px) {\n .display-6 {\n font-size: 2.5rem;\n }\n}\n\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline-item {\n display: inline-block;\n}\n.list-inline-item:not(:last-child) {\n margin-right: 0.5rem;\n}\n\n.initialism {\n font-size: 0.875em;\n text-transform: uppercase;\n}\n\n.blockquote {\n margin-bottom: 1rem;\n font-size: 1.25rem;\n}\n.blockquote > :last-child {\n margin-bottom: 0;\n}\n\n.blockquote-footer {\n margin-top: -1rem;\n margin-bottom: 1rem;\n font-size: 0.875em;\n color: #6c757d;\n}\n.blockquote-footer::before {\n content: \"— \";\n}\n\n.img-fluid {\n max-width: 100%;\n height: auto;\n}\n\n.img-thumbnail {\n padding: 0.25rem;\n background-color: #fff;\n border: 1px solid #dee2e6;\n border-radius: 0.25rem;\n max-width: 100%;\n height: auto;\n}\n\n.figure {\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: 0.5rem;\n line-height: 1;\n}\n\n.figure-caption {\n font-size: 0.875em;\n color: #6c757d;\n}\n\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n width: 100%;\n padding-right: var(--bs-gutter-x, 0.75rem);\n padding-left: var(--bs-gutter-x, 0.75rem);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(var(--bs-gutter-y) * -1);\n margin-right: calc(var(--bs-gutter-x) * -.5);\n margin-left: calc(var(--bs-gutter-x) * -.5);\n}\n.row > * {\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * .5);\n padding-left: calc(var(--bs-gutter-x) * .5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.3333333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.6666666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.3333333333%;\n }\n\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.6666666667%;\n }\n\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .offset-sm-0 {\n margin-left: 0;\n }\n\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n\n .offset-sm-3 {\n margin-left: 25%;\n }\n\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n\n .offset-sm-6 {\n margin-left: 50%;\n }\n\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n\n .offset-sm-9 {\n margin-left: 75%;\n }\n\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n\n .g-sm-0,\n.gx-sm-0 {\n --bs-gutter-x: 0;\n }\n\n .g-sm-0,\n.gy-sm-0 {\n --bs-gutter-y: 0;\n }\n\n .g-sm-1,\n.gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n\n .g-sm-1,\n.gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n\n .g-sm-2,\n.gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n\n .g-sm-2,\n.gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n\n .g-sm-3,\n.gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n\n .g-sm-3,\n.gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n\n .g-sm-4,\n.gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n\n .g-sm-4,\n.gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n\n .g-sm-5,\n.gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n\n .g-sm-5,\n.gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.3333333333%;\n }\n\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.6666666667%;\n }\n\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .offset-md-0 {\n margin-left: 0;\n }\n\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n\n .offset-md-3 {\n margin-left: 25%;\n }\n\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n\n .offset-md-6 {\n margin-left: 50%;\n }\n\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n\n .offset-md-9 {\n margin-left: 75%;\n }\n\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n\n .g-md-0,\n.gx-md-0 {\n --bs-gutter-x: 0;\n }\n\n .g-md-0,\n.gy-md-0 {\n --bs-gutter-y: 0;\n }\n\n .g-md-1,\n.gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n\n .g-md-1,\n.gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n\n .g-md-2,\n.gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n\n .g-md-2,\n.gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n\n .g-md-3,\n.gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n\n .g-md-3,\n.gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n\n .g-md-4,\n.gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n\n .g-md-4,\n.gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n\n .g-md-5,\n.gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n\n .g-md-5,\n.gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.3333333333%;\n }\n\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.6666666667%;\n }\n\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .offset-lg-0 {\n margin-left: 0;\n }\n\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n\n .offset-lg-3 {\n margin-left: 25%;\n }\n\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n\n .offset-lg-6 {\n margin-left: 50%;\n }\n\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n\n .offset-lg-9 {\n margin-left: 75%;\n }\n\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n\n .g-lg-0,\n.gx-lg-0 {\n --bs-gutter-x: 0;\n }\n\n .g-lg-0,\n.gy-lg-0 {\n --bs-gutter-y: 0;\n }\n\n .g-lg-1,\n.gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n\n .g-lg-1,\n.gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n\n .g-lg-2,\n.gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n\n .g-lg-2,\n.gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n\n .g-lg-3,\n.gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n\n .g-lg-3,\n.gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n\n .g-lg-4,\n.gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n\n .g-lg-4,\n.gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n\n .g-lg-5,\n.gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n\n .g-lg-5,\n.gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.3333333333%;\n }\n\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.6666666667%;\n }\n\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .offset-xl-0 {\n margin-left: 0;\n }\n\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n\n .offset-xl-3 {\n margin-left: 25%;\n }\n\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n\n .offset-xl-6 {\n margin-left: 50%;\n }\n\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n\n .offset-xl-9 {\n margin-left: 75%;\n }\n\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n\n .g-xl-0,\n.gx-xl-0 {\n --bs-gutter-x: 0;\n }\n\n .g-xl-0,\n.gy-xl-0 {\n --bs-gutter-y: 0;\n }\n\n .g-xl-1,\n.gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n\n .g-xl-1,\n.gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n\n .g-xl-2,\n.gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n\n .g-xl-2,\n.gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n\n .g-xl-3,\n.gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n\n .g-xl-3,\n.gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n\n .g-xl-4,\n.gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n\n .g-xl-4,\n.gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n\n .g-xl-5,\n.gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n\n .g-xl-5,\n.gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.3333333333%;\n }\n\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.6666666667%;\n }\n\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n\n .offset-xxl-0 {\n margin-left: 0;\n }\n\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n\n .offset-xxl-3 {\n margin-left: 25%;\n }\n\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n\n .offset-xxl-6 {\n margin-left: 50%;\n }\n\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n\n .offset-xxl-9 {\n margin-left: 75%;\n }\n\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n\n .g-xxl-0,\n.gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n\n .g-xxl-0,\n.gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n\n .g-xxl-1,\n.gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n\n .g-xxl-1,\n.gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n\n .g-xxl-2,\n.gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n\n .g-xxl-2,\n.gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n\n .g-xxl-3,\n.gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n\n .g-xxl-3,\n.gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n\n .g-xxl-4,\n.gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n\n .g-xxl-4,\n.gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n\n .g-xxl-5,\n.gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n\n .g-xxl-5,\n.gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.table {\n --bs-table-bg: transparent;\n --bs-table-accent-bg: transparent;\n --bs-table-striped-color: #212529;\n --bs-table-striped-bg: rgba(0, 0, 0, 0.05);\n --bs-table-active-color: #212529;\n --bs-table-active-bg: rgba(0, 0, 0, 0.1);\n --bs-table-hover-color: #212529;\n --bs-table-hover-bg: rgba(0, 0, 0, 0.075);\n width: 100%;\n margin-bottom: 1rem;\n color: #212529;\n vertical-align: top;\n border-color: #dee2e6;\n}\n.table > :not(caption) > * > * {\n padding: 0.5rem 0.5rem;\n background-color: var(--bs-table-bg);\n border-bottom-width: 1px;\n box-shadow: inset 0 0 0 9999px var(--bs-table-accent-bg);\n}\n.table > tbody {\n vertical-align: inherit;\n}\n.table > thead {\n vertical-align: bottom;\n}\n.table > :not(:last-child) > :last-child > * {\n border-bottom-color: currentColor;\n}\n\n.caption-top {\n caption-side: top;\n}\n\n.table-sm > :not(caption) > * > * {\n padding: 0.25rem 0.25rem;\n}\n\n.table-bordered > :not(caption) > * {\n border-width: 1px 0;\n}\n.table-bordered > :not(caption) > * > * {\n border-width: 0 1px;\n}\n\n.table-borderless > :not(caption) > * > * {\n border-bottom-width: 0;\n}\n\n.table-striped > tbody > tr:nth-of-type(odd) {\n --bs-table-accent-bg: var(--bs-table-striped-bg);\n color: var(--bs-table-striped-color);\n}\n\n.table-active {\n --bs-table-accent-bg: var(--bs-table-active-bg);\n color: var(--bs-table-active-color);\n}\n\n.table-hover > tbody > tr:hover {\n --bs-table-accent-bg: var(--bs-table-hover-bg);\n color: var(--bs-table-hover-color);\n}\n\n.table-primary {\n --bs-table-bg: #cfe2ff;\n --bs-table-striped-bg: #c5d7f2;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #bacbe6;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #bfd1ec;\n --bs-table-hover-color: #000;\n color: #000;\n border-color: #bacbe6;\n}\n\n.table-secondary {\n --bs-table-bg: #e2e3e5;\n --bs-table-striped-bg: #d7d8da;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #cbccce;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #d1d2d4;\n --bs-table-hover-color: #000;\n color: #000;\n border-color: #cbccce;\n}\n\n.table-success {\n --bs-table-bg: #d1e7dd;\n --bs-table-striped-bg: #c7dbd2;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #bcd0c7;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #c1d6cc;\n --bs-table-hover-color: #000;\n color: #000;\n border-color: #bcd0c7;\n}\n\n.table-info {\n --bs-table-bg: #cff4fc;\n --bs-table-striped-bg: #c5e8ef;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #badce3;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #bfe2e9;\n --bs-table-hover-color: #000;\n color: #000;\n border-color: #badce3;\n}\n\n.table-warning {\n --bs-table-bg: #fff3cd;\n --bs-table-striped-bg: #f2e7c3;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #e6dbb9;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #ece1be;\n --bs-table-hover-color: #000;\n color: #000;\n border-color: #e6dbb9;\n}\n\n.table-danger {\n --bs-table-bg: #f8d7da;\n --bs-table-striped-bg: #eccccf;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #dfc2c4;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #e5c7ca;\n --bs-table-hover-color: #000;\n color: #000;\n border-color: #dfc2c4;\n}\n\n.table-light {\n --bs-table-bg: #f8f9fa;\n --bs-table-striped-bg: #ecedee;\n --bs-table-striped-color: #000;\n --bs-table-active-bg: #dfe0e1;\n --bs-table-active-color: #000;\n --bs-table-hover-bg: #e5e6e7;\n --bs-table-hover-color: #000;\n color: #000;\n border-color: #dfe0e1;\n}\n\n.table-dark {\n --bs-table-bg: #212529;\n --bs-table-striped-bg: #2c3034;\n --bs-table-striped-color: #fff;\n --bs-table-active-bg: #373b3e;\n --bs-table-active-color: #fff;\n --bs-table-hover-bg: #323539;\n --bs-table-hover-color: #fff;\n color: #fff;\n border-color: #373b3e;\n}\n\n.table-responsive {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n}\n\n@media (max-width: 575.98px) {\n .table-responsive-sm {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 767.98px) {\n .table-responsive-md {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 991.98px) {\n .table-responsive-lg {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 1199.98px) {\n .table-responsive-xl {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n@media (max-width: 1399.98px) {\n .table-responsive-xxl {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n}\n.form-label {\n margin-bottom: 0.5rem;\n}\n\n.col-form-label {\n padding-top: calc(0.375rem + 1px);\n padding-bottom: calc(0.375rem + 1px);\n margin-bottom: 0;\n font-size: inherit;\n line-height: 1.5;\n}\n\n.col-form-label-lg {\n padding-top: calc(0.5rem + 1px);\n padding-bottom: calc(0.5rem + 1px);\n font-size: 1.25rem;\n}\n\n.col-form-label-sm {\n padding-top: calc(0.25rem + 1px);\n padding-bottom: calc(0.25rem + 1px);\n font-size: 0.875rem;\n}\n\n.form-text {\n margin-top: 0.25rem;\n font-size: 0.875em;\n color: #6c757d;\n}\n\n.form-control {\n display: block;\n width: 100%;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ced4da;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n border-radius: 0.25rem;\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-control {\n transition: none;\n }\n}\n.form-control[type=file] {\n overflow: hidden;\n}\n.form-control[type=file]:not(:disabled):not([readonly]) {\n cursor: pointer;\n}\n.form-control:focus {\n color: #212529;\n background-color: #fff;\n border-color: #86b7fe;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-control::-webkit-date-and-time-value {\n height: 1.5em;\n}\n.form-control::-moz-placeholder {\n color: #6c757d;\n opacity: 1;\n}\n.form-control::placeholder {\n color: #6c757d;\n opacity: 1;\n}\n.form-control:disabled, .form-control[readonly] {\n background-color: #e9ecef;\n opacity: 1;\n}\n.form-control::file-selector-button {\n padding: 0.375rem 0.75rem;\n margin: -0.375rem -0.75rem;\n -webkit-margin-end: 0.75rem;\n margin-inline-end: 0.75rem;\n color: #212529;\n background-color: #e9ecef;\n pointer-events: none;\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n border-inline-end-width: 1px;\n border-radius: 0;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-control::file-selector-button {\n transition: none;\n }\n}\n.form-control:hover:not(:disabled):not([readonly])::file-selector-button {\n background-color: #dde0e3;\n}\n.form-control::-webkit-file-upload-button {\n padding: 0.375rem 0.75rem;\n margin: -0.375rem -0.75rem;\n -webkit-margin-end: 0.75rem;\n margin-inline-end: 0.75rem;\n color: #212529;\n background-color: #e9ecef;\n pointer-events: none;\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n border-inline-end-width: 1px;\n border-radius: 0;\n -webkit-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-control::-webkit-file-upload-button {\n -webkit-transition: none;\n transition: none;\n }\n}\n.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button {\n background-color: #dde0e3;\n}\n\n.form-control-plaintext {\n display: block;\n width: 100%;\n padding: 0.375rem 0;\n margin-bottom: 0;\n line-height: 1.5;\n color: #212529;\n background-color: transparent;\n border: solid transparent;\n border-width: 1px 0;\n}\n.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg {\n padding-right: 0;\n padding-left: 0;\n}\n\n.form-control-sm {\n min-height: calc(1.5em + 0.5rem + 2px);\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n border-radius: 0.2rem;\n}\n.form-control-sm::file-selector-button {\n padding: 0.25rem 0.5rem;\n margin: -0.25rem -0.5rem;\n -webkit-margin-end: 0.5rem;\n margin-inline-end: 0.5rem;\n}\n.form-control-sm::-webkit-file-upload-button {\n padding: 0.25rem 0.5rem;\n margin: -0.25rem -0.5rem;\n -webkit-margin-end: 0.5rem;\n margin-inline-end: 0.5rem;\n}\n\n.form-control-lg {\n min-height: calc(1.5em + 1rem + 2px);\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n border-radius: 0.3rem;\n}\n.form-control-lg::file-selector-button {\n padding: 0.5rem 1rem;\n margin: -0.5rem -1rem;\n -webkit-margin-end: 1rem;\n margin-inline-end: 1rem;\n}\n.form-control-lg::-webkit-file-upload-button {\n padding: 0.5rem 1rem;\n margin: -0.5rem -1rem;\n -webkit-margin-end: 1rem;\n margin-inline-end: 1rem;\n}\n\ntextarea.form-control {\n min-height: calc(1.5em + 0.75rem + 2px);\n}\ntextarea.form-control-sm {\n min-height: calc(1.5em + 0.5rem + 2px);\n}\ntextarea.form-control-lg {\n min-height: calc(1.5em + 1rem + 2px);\n}\n\n.form-control-color {\n width: 3rem;\n height: auto;\n padding: 0.375rem;\n}\n.form-control-color:not(:disabled):not([readonly]) {\n cursor: pointer;\n}\n.form-control-color::-moz-color-swatch {\n height: 1.5em;\n border-radius: 0.25rem;\n}\n.form-control-color::-webkit-color-swatch {\n height: 1.5em;\n border-radius: 0.25rem;\n}\n\n.form-select {\n display: block;\n width: 100%;\n padding: 0.375rem 2.25rem 0.375rem 0.75rem;\n -moz-padding-start: calc(0.75rem - 3px);\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n background-color: #fff;\n 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\");\n background-repeat: no-repeat;\n background-position: right 0.75rem center;\n background-size: 16px 12px;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-select {\n transition: none;\n }\n}\n.form-select:focus {\n border-color: #86b7fe;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-select[multiple], .form-select[size]:not([size=\"1\"]) {\n padding-right: 0.75rem;\n background-image: none;\n}\n.form-select:disabled {\n background-color: #e9ecef;\n}\n.form-select:-moz-focusring {\n color: transparent;\n text-shadow: 0 0 0 #212529;\n}\n\n.form-select-sm {\n padding-top: 0.25rem;\n padding-bottom: 0.25rem;\n padding-left: 0.5rem;\n font-size: 0.875rem;\n}\n\n.form-select-lg {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n padding-left: 1rem;\n font-size: 1.25rem;\n}\n\n.form-check {\n display: block;\n min-height: 1.5rem;\n padding-left: 1.5em;\n margin-bottom: 0.125rem;\n}\n.form-check .form-check-input {\n float: left;\n margin-left: -1.5em;\n}\n\n.form-check-input {\n width: 1em;\n height: 1em;\n margin-top: 0.25em;\n vertical-align: top;\n background-color: #fff;\n background-repeat: no-repeat;\n background-position: center;\n background-size: contain;\n border: 1px solid rgba(0, 0, 0, 0.25);\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n -webkit-print-color-adjust: exact;\n color-adjust: exact;\n}\n.form-check-input[type=checkbox] {\n border-radius: 0.25em;\n}\n.form-check-input[type=radio] {\n border-radius: 50%;\n}\n.form-check-input:active {\n filter: brightness(90%);\n}\n.form-check-input:focus {\n border-color: #86b7fe;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-check-input:checked {\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n.form-check-input:checked[type=checkbox] {\n 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\");\n}\n.form-check-input:checked[type=radio] {\n 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\");\n}\n.form-check-input[type=checkbox]:indeterminate {\n background-color: #0d6efd;\n border-color: #0d6efd;\n 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\");\n}\n.form-check-input:disabled {\n pointer-events: none;\n filter: none;\n opacity: 0.5;\n}\n.form-check-input[disabled] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label {\n opacity: 0.5;\n}\n\n.form-switch {\n padding-left: 2.5em;\n}\n.form-switch .form-check-input {\n width: 2em;\n margin-left: -2.5em;\n 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\");\n background-position: left center;\n border-radius: 2em;\n transition: background-position 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-switch .form-check-input {\n transition: none;\n }\n}\n.form-switch .form-check-input:focus {\n 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\");\n}\n.form-switch .form-check-input:checked {\n background-position: right center;\n 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\");\n}\n\n.form-check-inline {\n display: inline-block;\n margin-right: 1rem;\n}\n\n.btn-check {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.btn-check[disabled] + .btn, .btn-check:disabled + .btn {\n pointer-events: none;\n filter: none;\n opacity: 0.65;\n}\n\n.form-range {\n width: 100%;\n height: 1.5rem;\n padding: 0;\n background-color: transparent;\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n}\n.form-range:focus {\n outline: 0;\n}\n.form-range:focus::-webkit-slider-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-range:focus::-moz-range-thumb {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.form-range::-moz-focus-outer {\n border: 0;\n}\n.form-range::-webkit-slider-thumb {\n width: 1rem;\n height: 1rem;\n margin-top: -0.25rem;\n background-color: #0d6efd;\n border: 0;\n border-radius: 1rem;\n -webkit-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n -webkit-appearance: none;\n appearance: none;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-range::-webkit-slider-thumb {\n -webkit-transition: none;\n transition: none;\n }\n}\n.form-range::-webkit-slider-thumb:active {\n background-color: #b6d4fe;\n}\n.form-range::-webkit-slider-runnable-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n.form-range::-moz-range-thumb {\n width: 1rem;\n height: 1rem;\n background-color: #0d6efd;\n border: 0;\n border-radius: 1rem;\n -moz-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n -moz-appearance: none;\n appearance: none;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-range::-moz-range-thumb {\n -moz-transition: none;\n transition: none;\n }\n}\n.form-range::-moz-range-thumb:active {\n background-color: #b6d4fe;\n}\n.form-range::-moz-range-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n.form-range:disabled {\n pointer-events: none;\n}\n.form-range:disabled::-webkit-slider-thumb {\n background-color: #adb5bd;\n}\n.form-range:disabled::-moz-range-thumb {\n background-color: #adb5bd;\n}\n\n.form-floating {\n position: relative;\n}\n.form-floating > .form-control,\n.form-floating > .form-select {\n height: calc(3.5rem + 2px);\n line-height: 1.25;\n}\n.form-floating > label {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n padding: 1rem 0.75rem;\n pointer-events: none;\n border: 1px solid transparent;\n transform-origin: 0 0;\n transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .form-floating > label {\n transition: none;\n }\n}\n.form-floating > .form-control {\n padding: 1rem 0.75rem;\n}\n.form-floating > .form-control::-moz-placeholder {\n color: transparent;\n}\n.form-floating > .form-control::placeholder {\n color: transparent;\n}\n.form-floating > .form-control:not(:-moz-placeholder-shown) {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown) {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-control:-webkit-autofill {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-select {\n padding-top: 1.625rem;\n padding-bottom: 0.625rem;\n}\n.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label {\n opacity: 0.65;\n transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);\n}\n.form-floating > .form-control:focus ~ label,\n.form-floating > .form-control:not(:placeholder-shown) ~ label,\n.form-floating > .form-select ~ label {\n opacity: 0.65;\n transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);\n}\n.form-floating > .form-control:-webkit-autofill ~ label {\n opacity: 0.65;\n transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);\n}\n\n.input-group {\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: stretch;\n width: 100%;\n}\n.input-group > .form-control,\n.input-group > .form-select {\n position: relative;\n flex: 1 1 auto;\n width: 1%;\n min-width: 0;\n}\n.input-group > .form-control:focus,\n.input-group > .form-select:focus {\n z-index: 3;\n}\n.input-group .btn {\n position: relative;\n z-index: 2;\n}\n.input-group .btn:focus {\n z-index: 3;\n}\n\n.input-group-text {\n display: flex;\n align-items: center;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n text-align: center;\n white-space: nowrap;\n background-color: #e9ecef;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n}\n\n.input-group-lg > .form-control,\n.input-group-lg > .form-select,\n.input-group-lg > .input-group-text,\n.input-group-lg > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n border-radius: 0.3rem;\n}\n\n.input-group-sm > .form-control,\n.input-group-sm > .form-select,\n.input-group-sm > .input-group-text,\n.input-group-sm > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n border-radius: 0.2rem;\n}\n\n.input-group-lg > .form-select,\n.input-group-sm > .form-select {\n padding-right: 3rem;\n}\n\n.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu),\n.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.input-group.has-validation > :nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu),\n.input-group.has-validation > .dropdown-toggle:nth-last-child(n+4) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) {\n margin-left: -1px;\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.valid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 0.875em;\n color: #198754;\n}\n\n.valid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: 0.1rem;\n font-size: 0.875rem;\n color: #fff;\n background-color: rgba(25, 135, 84, 0.9);\n border-radius: 0.25rem;\n}\n\n.was-validated :valid ~ .valid-feedback,\n.was-validated :valid ~ .valid-tooltip,\n.is-valid ~ .valid-feedback,\n.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-control:valid, .form-control.is-valid {\n border-color: #198754;\n padding-right: calc(1.5em + 0.75rem);\n 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\");\n background-repeat: no-repeat;\n background-position: right calc(0.375em + 0.1875rem) center;\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-control:valid:focus, .form-control.is-valid:focus {\n border-color: #198754;\n box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);\n}\n\n.was-validated textarea.form-control:valid, textarea.form-control.is-valid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .form-select:valid, .form-select.is-valid {\n border-color: #198754;\n}\n.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size=\"1\"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size=\"1\"] {\n padding-right: 4.125rem;\n 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\");\n background-position: right 0.75rem center, center right 2.25rem;\n background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-select:valid:focus, .form-select.is-valid:focus {\n border-color: #198754;\n box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);\n}\n\n.was-validated .form-check-input:valid, .form-check-input.is-valid {\n border-color: #198754;\n}\n.was-validated .form-check-input:valid:checked, .form-check-input.is-valid:checked {\n background-color: #198754;\n}\n.was-validated .form-check-input:valid:focus, .form-check-input.is-valid:focus {\n box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);\n}\n.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label {\n color: #198754;\n}\n\n.form-check-inline .form-check-input ~ .valid-feedback {\n margin-left: 0.5em;\n}\n\n.was-validated .input-group .form-control:valid, .input-group .form-control.is-valid,\n.was-validated .input-group .form-select:valid,\n.input-group .form-select.is-valid {\n z-index: 1;\n}\n.was-validated .input-group .form-control:valid:focus, .input-group .form-control.is-valid:focus,\n.was-validated .input-group .form-select:valid:focus,\n.input-group .form-select.is-valid:focus {\n z-index: 3;\n}\n\n.invalid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 0.875em;\n color: #dc3545;\n}\n\n.invalid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: 0.25rem 0.5rem;\n margin-top: 0.1rem;\n font-size: 0.875rem;\n color: #fff;\n background-color: rgba(220, 53, 69, 0.9);\n border-radius: 0.25rem;\n}\n\n.was-validated :invalid ~ .invalid-feedback,\n.was-validated :invalid ~ .invalid-tooltip,\n.is-invalid ~ .invalid-feedback,\n.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-control:invalid, .form-control.is-invalid {\n border-color: #dc3545;\n padding-right: calc(1.5em + 0.75rem);\n 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\");\n background-repeat: no-repeat;\n background-position: right calc(0.375em + 0.1875rem) center;\n background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated textarea.form-control:invalid, textarea.form-control.is-invalid {\n padding-right: calc(1.5em + 0.75rem);\n background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem);\n}\n\n.was-validated .form-select:invalid, .form-select.is-invalid {\n border-color: #dc3545;\n}\n.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size=\"1\"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size=\"1\"] {\n padding-right: 4.125rem;\n 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\");\n background-position: right 0.75rem center, center right 2.25rem;\n background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);\n}\n.was-validated .form-select:invalid:focus, .form-select.is-invalid:focus {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .form-check-input:invalid, .form-check-input.is-invalid {\n border-color: #dc3545;\n}\n.was-validated .form-check-input:invalid:checked, .form-check-input.is-invalid:checked {\n background-color: #dc3545;\n}\n.was-validated .form-check-input:invalid:focus, .form-check-input.is-invalid:focus {\n box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25);\n}\n.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label {\n color: #dc3545;\n}\n\n.form-check-inline .form-check-input ~ .invalid-feedback {\n margin-left: 0.5em;\n}\n\n.was-validated .input-group .form-control:invalid, .input-group .form-control.is-invalid,\n.was-validated .input-group .form-select:invalid,\n.input-group .form-select.is-invalid {\n z-index: 2;\n}\n.was-validated .input-group .form-control:invalid:focus, .input-group .form-control.is-invalid:focus,\n.was-validated .input-group .form-select:invalid:focus,\n.input-group .form-select.is-invalid:focus {\n z-index: 3;\n}\n\n.btn {\n display: inline-block;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n text-align: center;\n text-decoration: none;\n vertical-align: middle;\n cursor: pointer;\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n background-color: transparent;\n border: 1px solid transparent;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n border-radius: 0.25rem;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .btn {\n transition: none;\n }\n}\n.btn:hover {\n color: #212529;\n}\n.btn-check:focus + .btn, .btn:focus {\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n.btn:disabled, .btn.disabled, fieldset:disabled .btn {\n pointer-events: none;\n opacity: 0.65;\n}\n\n.btn-primary {\n color: #fff;\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #0b5ed7;\n border-color: #0a58ca;\n}\n.btn-check:focus + .btn-primary, .btn-primary:focus {\n color: #fff;\n background-color: #0b5ed7;\n border-color: #0a58ca;\n box-shadow: 0 0 0 0.25rem rgba(49, 132, 253, 0.5);\n}\n.btn-check:checked + .btn-primary, .btn-check:active + .btn-primary, .btn-primary:active, .btn-primary.active, .show > .btn-primary.dropdown-toggle {\n color: #fff;\n background-color: #0a58ca;\n border-color: #0a53be;\n}\n.btn-check:checked + .btn-primary:focus, .btn-check:active + .btn-primary:focus, .btn-primary:active:focus, .btn-primary.active:focus, .show > .btn-primary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(49, 132, 253, 0.5);\n}\n.btn-primary:disabled, .btn-primary.disabled {\n color: #fff;\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n\n.btn-secondary {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n.btn-secondary:hover {\n color: #fff;\n background-color: #5c636a;\n border-color: #565e64;\n}\n.btn-check:focus + .btn-secondary, .btn-secondary:focus {\n color: #fff;\n background-color: #5c636a;\n border-color: #565e64;\n box-shadow: 0 0 0 0.25rem rgba(130, 138, 145, 0.5);\n}\n.btn-check:checked + .btn-secondary, .btn-check:active + .btn-secondary, .btn-secondary:active, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle {\n color: #fff;\n background-color: #565e64;\n border-color: #51585e;\n}\n.btn-check:checked + .btn-secondary:focus, .btn-check:active + .btn-secondary:focus, .btn-secondary:active:focus, .btn-secondary.active:focus, .show > .btn-secondary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(130, 138, 145, 0.5);\n}\n.btn-secondary:disabled, .btn-secondary.disabled {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-success {\n color: #fff;\n background-color: #198754;\n border-color: #198754;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #157347;\n border-color: #146c43;\n}\n.btn-check:focus + .btn-success, .btn-success:focus {\n color: #fff;\n background-color: #157347;\n border-color: #146c43;\n box-shadow: 0 0 0 0.25rem rgba(60, 153, 110, 0.5);\n}\n.btn-check:checked + .btn-success, .btn-check:active + .btn-success, .btn-success:active, .btn-success.active, .show > .btn-success.dropdown-toggle {\n color: #fff;\n background-color: #146c43;\n border-color: #13653f;\n}\n.btn-check:checked + .btn-success:focus, .btn-check:active + .btn-success:focus, .btn-success:active:focus, .btn-success.active:focus, .show > .btn-success.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(60, 153, 110, 0.5);\n}\n.btn-success:disabled, .btn-success.disabled {\n color: #fff;\n background-color: #198754;\n border-color: #198754;\n}\n\n.btn-info {\n color: #000;\n background-color: #0dcaf0;\n border-color: #0dcaf0;\n}\n.btn-info:hover {\n color: #000;\n background-color: #31d2f2;\n border-color: #25cff2;\n}\n.btn-check:focus + .btn-info, .btn-info:focus {\n color: #000;\n background-color: #31d2f2;\n border-color: #25cff2;\n box-shadow: 0 0 0 0.25rem rgba(11, 172, 204, 0.5);\n}\n.btn-check:checked + .btn-info, .btn-check:active + .btn-info, .btn-info:active, .btn-info.active, .show > .btn-info.dropdown-toggle {\n color: #000;\n background-color: #3dd5f3;\n border-color: #25cff2;\n}\n.btn-check:checked + .btn-info:focus, .btn-check:active + .btn-info:focus, .btn-info:active:focus, .btn-info.active:focus, .show > .btn-info.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(11, 172, 204, 0.5);\n}\n.btn-info:disabled, .btn-info.disabled {\n color: #000;\n background-color: #0dcaf0;\n border-color: #0dcaf0;\n}\n\n.btn-warning {\n color: #000;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n.btn-warning:hover {\n color: #000;\n background-color: #ffca2c;\n border-color: #ffc720;\n}\n.btn-check:focus + .btn-warning, .btn-warning:focus {\n color: #000;\n background-color: #ffca2c;\n border-color: #ffc720;\n box-shadow: 0 0 0 0.25rem rgba(217, 164, 6, 0.5);\n}\n.btn-check:checked + .btn-warning, .btn-check:active + .btn-warning, .btn-warning:active, .btn-warning.active, .show > .btn-warning.dropdown-toggle {\n color: #000;\n background-color: #ffcd39;\n border-color: #ffc720;\n}\n.btn-check:checked + .btn-warning:focus, .btn-check:active + .btn-warning:focus, .btn-warning:active:focus, .btn-warning.active:focus, .show > .btn-warning.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(217, 164, 6, 0.5);\n}\n.btn-warning:disabled, .btn-warning.disabled {\n color: #000;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-danger {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #bb2d3b;\n border-color: #b02a37;\n}\n.btn-check:focus + .btn-danger, .btn-danger:focus {\n color: #fff;\n background-color: #bb2d3b;\n border-color: #b02a37;\n box-shadow: 0 0 0 0.25rem rgba(225, 83, 97, 0.5);\n}\n.btn-check:checked + .btn-danger, .btn-check:active + .btn-danger, .btn-danger:active, .btn-danger.active, .show > .btn-danger.dropdown-toggle {\n color: #fff;\n background-color: #b02a37;\n border-color: #a52834;\n}\n.btn-check:checked + .btn-danger:focus, .btn-check:active + .btn-danger:focus, .btn-danger:active:focus, .btn-danger.active:focus, .show > .btn-danger.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(225, 83, 97, 0.5);\n}\n.btn-danger:disabled, .btn-danger.disabled {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-light {\n color: #000;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n.btn-light:hover {\n color: #000;\n background-color: #f9fafb;\n border-color: #f9fafb;\n}\n.btn-check:focus + .btn-light, .btn-light:focus {\n color: #000;\n background-color: #f9fafb;\n border-color: #f9fafb;\n box-shadow: 0 0 0 0.25rem rgba(211, 212, 213, 0.5);\n}\n.btn-check:checked + .btn-light, .btn-check:active + .btn-light, .btn-light:active, .btn-light.active, .show > .btn-light.dropdown-toggle {\n color: #000;\n background-color: #f9fafb;\n border-color: #f9fafb;\n}\n.btn-check:checked + .btn-light:focus, .btn-check:active + .btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show > .btn-light.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(211, 212, 213, 0.5);\n}\n.btn-light:disabled, .btn-light.disabled {\n color: #000;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-dark {\n color: #fff;\n background-color: #212529;\n border-color: #212529;\n}\n.btn-dark:hover {\n color: #fff;\n background-color: #1c1f23;\n border-color: #1a1e21;\n}\n.btn-check:focus + .btn-dark, .btn-dark:focus {\n color: #fff;\n background-color: #1c1f23;\n border-color: #1a1e21;\n box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5);\n}\n.btn-check:checked + .btn-dark, .btn-check:active + .btn-dark, .btn-dark:active, .btn-dark.active, .show > .btn-dark.dropdown-toggle {\n color: #fff;\n background-color: #1a1e21;\n border-color: #191c1f;\n}\n.btn-check:checked + .btn-dark:focus, .btn-check:active + .btn-dark:focus, .btn-dark:active:focus, .btn-dark.active:focus, .show > .btn-dark.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5);\n}\n.btn-dark:disabled, .btn-dark.disabled {\n color: #fff;\n background-color: #212529;\n border-color: #212529;\n}\n\n.btn-outline-primary {\n color: #0d6efd;\n border-color: #0d6efd;\n}\n.btn-outline-primary:hover {\n color: #fff;\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n.btn-check:focus + .btn-outline-primary, .btn-outline-primary:focus {\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.5);\n}\n.btn-check:checked + .btn-outline-primary, .btn-check:active + .btn-outline-primary, .btn-outline-primary:active, .btn-outline-primary.active, .btn-outline-primary.dropdown-toggle.show {\n color: #fff;\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n.btn-check:checked + .btn-outline-primary:focus, .btn-check:active + .btn-outline-primary:focus, .btn-outline-primary:active:focus, .btn-outline-primary.active:focus, .btn-outline-primary.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.5);\n}\n.btn-outline-primary:disabled, .btn-outline-primary.disabled {\n color: #0d6efd;\n background-color: transparent;\n}\n\n.btn-outline-secondary {\n color: #6c757d;\n border-color: #6c757d;\n}\n.btn-outline-secondary:hover {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n.btn-check:focus + .btn-outline-secondary, .btn-outline-secondary:focus {\n box-shadow: 0 0 0 0.25rem rgba(108, 117, 125, 0.5);\n}\n.btn-check:checked + .btn-outline-secondary, .btn-check:active + .btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n.btn-check:checked + .btn-outline-secondary:focus, .btn-check:active + .btn-outline-secondary:focus, .btn-outline-secondary:active:focus, .btn-outline-secondary.active:focus, .btn-outline-secondary.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(108, 117, 125, 0.5);\n}\n.btn-outline-secondary:disabled, .btn-outline-secondary.disabled {\n color: #6c757d;\n background-color: transparent;\n}\n\n.btn-outline-success {\n color: #198754;\n border-color: #198754;\n}\n.btn-outline-success:hover {\n color: #fff;\n background-color: #198754;\n border-color: #198754;\n}\n.btn-check:focus + .btn-outline-success, .btn-outline-success:focus {\n box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.5);\n}\n.btn-check:checked + .btn-outline-success, .btn-check:active + .btn-outline-success, .btn-outline-success:active, .btn-outline-success.active, .btn-outline-success.dropdown-toggle.show {\n color: #fff;\n background-color: #198754;\n border-color: #198754;\n}\n.btn-check:checked + .btn-outline-success:focus, .btn-check:active + .btn-outline-success:focus, .btn-outline-success:active:focus, .btn-outline-success.active:focus, .btn-outline-success.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.5);\n}\n.btn-outline-success:disabled, .btn-outline-success.disabled {\n color: #198754;\n background-color: transparent;\n}\n\n.btn-outline-info {\n color: #0dcaf0;\n border-color: #0dcaf0;\n}\n.btn-outline-info:hover {\n color: #000;\n background-color: #0dcaf0;\n border-color: #0dcaf0;\n}\n.btn-check:focus + .btn-outline-info, .btn-outline-info:focus {\n box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.5);\n}\n.btn-check:checked + .btn-outline-info, .btn-check:active + .btn-outline-info, .btn-outline-info:active, .btn-outline-info.active, .btn-outline-info.dropdown-toggle.show {\n color: #000;\n background-color: #0dcaf0;\n border-color: #0dcaf0;\n}\n.btn-check:checked + .btn-outline-info:focus, .btn-check:active + .btn-outline-info:focus, .btn-outline-info:active:focus, .btn-outline-info.active:focus, .btn-outline-info.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.5);\n}\n.btn-outline-info:disabled, .btn-outline-info.disabled {\n color: #0dcaf0;\n background-color: transparent;\n}\n\n.btn-outline-warning {\n color: #ffc107;\n border-color: #ffc107;\n}\n.btn-outline-warning:hover {\n color: #000;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n.btn-check:focus + .btn-outline-warning, .btn-outline-warning:focus {\n box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5);\n}\n.btn-check:checked + .btn-outline-warning, .btn-check:active + .btn-outline-warning, .btn-outline-warning:active, .btn-outline-warning.active, .btn-outline-warning.dropdown-toggle.show {\n color: #000;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n.btn-check:checked + .btn-outline-warning:focus, .btn-check:active + .btn-outline-warning:focus, .btn-outline-warning:active:focus, .btn-outline-warning.active:focus, .btn-outline-warning.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5);\n}\n.btn-outline-warning:disabled, .btn-outline-warning.disabled {\n color: #ffc107;\n background-color: transparent;\n}\n\n.btn-outline-danger {\n color: #dc3545;\n border-color: #dc3545;\n}\n.btn-outline-danger:hover {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n.btn-check:focus + .btn-outline-danger, .btn-outline-danger:focus {\n box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.5);\n}\n.btn-check:checked + .btn-outline-danger, .btn-check:active + .btn-outline-danger, .btn-outline-danger:active, .btn-outline-danger.active, .btn-outline-danger.dropdown-toggle.show {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n.btn-check:checked + .btn-outline-danger:focus, .btn-check:active + .btn-outline-danger:focus, .btn-outline-danger:active:focus, .btn-outline-danger.active:focus, .btn-outline-danger.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.5);\n}\n.btn-outline-danger:disabled, .btn-outline-danger.disabled {\n color: #dc3545;\n background-color: transparent;\n}\n\n.btn-outline-light {\n color: #f8f9fa;\n border-color: #f8f9fa;\n}\n.btn-outline-light:hover {\n color: #000;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n.btn-check:focus + .btn-outline-light, .btn-outline-light:focus {\n box-shadow: 0 0 0 0.25rem rgba(248, 249, 250, 0.5);\n}\n.btn-check:checked + .btn-outline-light, .btn-check:active + .btn-outline-light, .btn-outline-light:active, .btn-outline-light.active, .btn-outline-light.dropdown-toggle.show {\n color: #000;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n.btn-check:checked + .btn-outline-light:focus, .btn-check:active + .btn-outline-light:focus, .btn-outline-light:active:focus, .btn-outline-light.active:focus, .btn-outline-light.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(248, 249, 250, 0.5);\n}\n.btn-outline-light:disabled, .btn-outline-light.disabled {\n color: #f8f9fa;\n background-color: transparent;\n}\n\n.btn-outline-dark {\n color: #212529;\n border-color: #212529;\n}\n.btn-outline-dark:hover {\n color: #fff;\n background-color: #212529;\n border-color: #212529;\n}\n.btn-check:focus + .btn-outline-dark, .btn-outline-dark:focus {\n box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);\n}\n.btn-check:checked + .btn-outline-dark, .btn-check:active + .btn-outline-dark, .btn-outline-dark:active, .btn-outline-dark.active, .btn-outline-dark.dropdown-toggle.show {\n color: #fff;\n background-color: #212529;\n border-color: #212529;\n}\n.btn-check:checked + .btn-outline-dark:focus, .btn-check:active + .btn-outline-dark:focus, .btn-outline-dark:active:focus, .btn-outline-dark.active:focus, .btn-outline-dark.dropdown-toggle.show:focus {\n box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);\n}\n.btn-outline-dark:disabled, .btn-outline-dark.disabled {\n color: #212529;\n background-color: transparent;\n}\n\n.btn-link {\n font-weight: 400;\n color: #0d6efd;\n text-decoration: underline;\n}\n.btn-link:hover {\n color: #0a58ca;\n}\n.btn-link:disabled, .btn-link.disabled {\n color: #6c757d;\n}\n\n.btn-lg, .btn-group-lg > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n border-radius: 0.3rem;\n}\n\n.btn-sm, .btn-group-sm > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n border-radius: 0.2rem;\n}\n\n.fade {\n transition: opacity 0.15s linear;\n}\n@media (prefers-reduced-motion: reduce) {\n .fade {\n transition: none;\n }\n}\n.fade:not(.show) {\n opacity: 0;\n}\n\n.collapse:not(.show) {\n display: none;\n}\n\n.collapsing {\n height: 0;\n overflow: hidden;\n transition: height 0.35s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .collapsing {\n transition: none;\n }\n}\n.collapsing.collapse-horizontal {\n width: 0;\n height: auto;\n transition: width 0.35s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .collapsing.collapse-horizontal {\n transition: none;\n }\n}\n\n.dropup,\n.dropend,\n.dropdown,\n.dropstart {\n position: relative;\n}\n\n.dropdown-toggle {\n white-space: nowrap;\n}\n.dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid;\n border-right: 0.3em solid transparent;\n border-bottom: 0;\n border-left: 0.3em solid transparent;\n}\n.dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropdown-menu {\n position: absolute;\n z-index: 1000;\n display: none;\n min-width: 10rem;\n padding: 0.5rem 0;\n margin: 0;\n font-size: 1rem;\n color: #212529;\n text-align: left;\n list-style: none;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 0.25rem;\n}\n.dropdown-menu[data-bs-popper] {\n top: 100%;\n left: 0;\n margin-top: 0.125rem;\n}\n\n.dropdown-menu-start {\n --bs-position: start;\n}\n.dropdown-menu-start[data-bs-popper] {\n right: auto;\n left: 0;\n}\n\n.dropdown-menu-end {\n --bs-position: end;\n}\n.dropdown-menu-end[data-bs-popper] {\n right: 0;\n left: auto;\n}\n\n@media (min-width: 576px) {\n .dropdown-menu-sm-start {\n --bs-position: start;\n }\n .dropdown-menu-sm-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n\n .dropdown-menu-sm-end {\n --bs-position: end;\n }\n .dropdown-menu-sm-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 768px) {\n .dropdown-menu-md-start {\n --bs-position: start;\n }\n .dropdown-menu-md-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n\n .dropdown-menu-md-end {\n --bs-position: end;\n }\n .dropdown-menu-md-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 992px) {\n .dropdown-menu-lg-start {\n --bs-position: start;\n }\n .dropdown-menu-lg-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n\n .dropdown-menu-lg-end {\n --bs-position: end;\n }\n .dropdown-menu-lg-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 1200px) {\n .dropdown-menu-xl-start {\n --bs-position: start;\n }\n .dropdown-menu-xl-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n\n .dropdown-menu-xl-end {\n --bs-position: end;\n }\n .dropdown-menu-xl-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n@media (min-width: 1400px) {\n .dropdown-menu-xxl-start {\n --bs-position: start;\n }\n .dropdown-menu-xxl-start[data-bs-popper] {\n right: auto;\n left: 0;\n }\n\n .dropdown-menu-xxl-end {\n --bs-position: end;\n }\n .dropdown-menu-xxl-end[data-bs-popper] {\n right: 0;\n left: auto;\n }\n}\n.dropup .dropdown-menu[data-bs-popper] {\n top: auto;\n bottom: 100%;\n margin-top: 0;\n margin-bottom: 0.125rem;\n}\n.dropup .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0;\n border-right: 0.3em solid transparent;\n border-bottom: 0.3em solid;\n border-left: 0.3em solid transparent;\n}\n.dropup .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropend .dropdown-menu[data-bs-popper] {\n top: 0;\n right: auto;\n left: 100%;\n margin-top: 0;\n margin-left: 0.125rem;\n}\n.dropend .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0;\n border-bottom: 0.3em solid transparent;\n border-left: 0.3em solid;\n}\n.dropend .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n.dropend .dropdown-toggle::after {\n vertical-align: 0;\n}\n\n.dropstart .dropdown-menu[data-bs-popper] {\n top: 0;\n right: 100%;\n left: auto;\n margin-top: 0;\n margin-right: 0.125rem;\n}\n.dropstart .dropdown-toggle::after {\n display: inline-block;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n}\n.dropstart .dropdown-toggle::after {\n display: none;\n}\n.dropstart .dropdown-toggle::before {\n display: inline-block;\n margin-right: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0.3em solid;\n border-bottom: 0.3em solid transparent;\n}\n.dropstart .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n.dropstart .dropdown-toggle::before {\n vertical-align: 0;\n}\n\n.dropdown-divider {\n height: 0;\n margin: 0.5rem 0;\n overflow: hidden;\n border-top: 1px solid rgba(0, 0, 0, 0.15);\n}\n\n.dropdown-item {\n display: block;\n width: 100%;\n padding: 0.25rem 1rem;\n clear: both;\n font-weight: 400;\n color: #212529;\n text-align: inherit;\n text-decoration: none;\n white-space: nowrap;\n background-color: transparent;\n border: 0;\n}\n.dropdown-item:hover, .dropdown-item:focus {\n color: #1e2125;\n background-color: #e9ecef;\n}\n.dropdown-item.active, .dropdown-item:active {\n color: #fff;\n text-decoration: none;\n background-color: #0d6efd;\n}\n.dropdown-item.disabled, .dropdown-item:disabled {\n color: #adb5bd;\n pointer-events: none;\n background-color: transparent;\n}\n\n.dropdown-menu.show {\n display: block;\n}\n\n.dropdown-header {\n display: block;\n padding: 0.5rem 1rem;\n margin-bottom: 0;\n font-size: 0.875rem;\n color: #6c757d;\n white-space: nowrap;\n}\n\n.dropdown-item-text {\n display: block;\n padding: 0.25rem 1rem;\n color: #212529;\n}\n\n.dropdown-menu-dark {\n color: #dee2e6;\n background-color: #343a40;\n border-color: rgba(0, 0, 0, 0.15);\n}\n.dropdown-menu-dark .dropdown-item {\n color: #dee2e6;\n}\n.dropdown-menu-dark .dropdown-item:hover, .dropdown-menu-dark .dropdown-item:focus {\n color: #fff;\n background-color: rgba(255, 255, 255, 0.15);\n}\n.dropdown-menu-dark .dropdown-item.active, .dropdown-menu-dark .dropdown-item:active {\n color: #fff;\n background-color: #0d6efd;\n}\n.dropdown-menu-dark .dropdown-item.disabled, .dropdown-menu-dark .dropdown-item:disabled {\n color: #adb5bd;\n}\n.dropdown-menu-dark .dropdown-divider {\n border-color: rgba(0, 0, 0, 0.15);\n}\n.dropdown-menu-dark .dropdown-item-text {\n color: #dee2e6;\n}\n.dropdown-menu-dark .dropdown-header {\n color: #adb5bd;\n}\n\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-flex;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n flex: 1 1 auto;\n}\n.btn-group > .btn-check:checked + .btn,\n.btn-group > .btn-check:focus + .btn,\n.btn-group > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn-check:checked + .btn,\n.btn-group-vertical > .btn-check:focus + .btn,\n.btn-group-vertical > .btn:hover,\n.btn-group-vertical > .btn:focus,\n.btn-group-vertical > .btn:active,\n.btn-group-vertical > .btn.active {\n z-index: 1;\n}\n\n.btn-toolbar {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-start;\n}\n.btn-toolbar .input-group {\n width: auto;\n}\n\n.btn-group > .btn:not(:first-child),\n.btn-group > .btn-group:not(:first-child) {\n margin-left: -1px;\n}\n.btn-group > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn:nth-child(n+3),\n.btn-group > :not(.btn-check) + .btn,\n.btn-group > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.dropdown-toggle-split {\n padding-right: 0.5625rem;\n padding-left: 0.5625rem;\n}\n.dropdown-toggle-split::after, .dropup .dropdown-toggle-split::after, .dropend .dropdown-toggle-split::after {\n margin-left: 0;\n}\n.dropstart .dropdown-toggle-split::before {\n margin-right: 0;\n}\n\n.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {\n padding-right: 0.375rem;\n padding-left: 0.375rem;\n}\n\n.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {\n padding-right: 0.75rem;\n padding-left: 0.75rem;\n}\n\n.btn-group-vertical {\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group {\n width: 100%;\n}\n.btn-group-vertical > .btn:not(:first-child),\n.btn-group-vertical > .btn-group:not(:first-child) {\n margin-top: -1px;\n}\n.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group-vertical > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn ~ .btn,\n.btn-group-vertical > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.nav {\n display: flex;\n flex-wrap: wrap;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.nav-link {\n display: block;\n padding: 0.5rem 1rem;\n color: #0d6efd;\n text-decoration: none;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .nav-link {\n transition: none;\n }\n}\n.nav-link:hover, .nav-link:focus {\n color: #0a58ca;\n}\n.nav-link.disabled {\n color: #6c757d;\n pointer-events: none;\n cursor: default;\n}\n\n.nav-tabs {\n border-bottom: 1px solid #dee2e6;\n}\n.nav-tabs .nav-link {\n margin-bottom: -1px;\n background: none;\n border: 1px solid transparent;\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {\n border-color: #e9ecef #e9ecef #dee2e6;\n isolation: isolate;\n}\n.nav-tabs .nav-link.disabled {\n color: #6c757d;\n background-color: transparent;\n border-color: transparent;\n}\n.nav-tabs .nav-link.active,\n.nav-tabs .nav-item.show .nav-link {\n color: #495057;\n background-color: #fff;\n border-color: #dee2e6 #dee2e6 #fff;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.nav-pills .nav-link {\n background: none;\n border: 0;\n border-radius: 0.25rem;\n}\n.nav-pills .nav-link.active,\n.nav-pills .show > .nav-link {\n color: #fff;\n background-color: #0d6efd;\n}\n\n.nav-fill > .nav-link,\n.nav-fill .nav-item {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.nav-justified > .nav-link,\n.nav-justified .nav-item {\n flex-basis: 0;\n flex-grow: 1;\n text-align: center;\n}\n\n.nav-fill .nav-item .nav-link,\n.nav-justified .nav-item .nav-link {\n width: 100%;\n}\n\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n\n.navbar {\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n}\n.navbar > .container,\n.navbar > .container-fluid,\n.navbar > .container-sm,\n.navbar > .container-md,\n.navbar > .container-lg,\n.navbar > .container-xl,\n.navbar > .container-xxl {\n display: flex;\n flex-wrap: inherit;\n align-items: center;\n justify-content: space-between;\n}\n.navbar-brand {\n padding-top: 0.3125rem;\n padding-bottom: 0.3125rem;\n margin-right: 1rem;\n font-size: 1.25rem;\n text-decoration: none;\n white-space: nowrap;\n}\n.navbar-nav {\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n.navbar-nav .nav-link {\n padding-right: 0;\n padding-left: 0;\n}\n.navbar-nav .dropdown-menu {\n position: static;\n}\n\n.navbar-text {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n}\n\n.navbar-collapse {\n flex-basis: 100%;\n flex-grow: 1;\n align-items: center;\n}\n\n.navbar-toggler {\n padding: 0.25rem 0.75rem;\n font-size: 1.25rem;\n line-height: 1;\n background-color: transparent;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n transition: box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .navbar-toggler {\n transition: none;\n }\n}\n.navbar-toggler:hover {\n text-decoration: none;\n}\n.navbar-toggler:focus {\n text-decoration: none;\n outline: 0;\n box-shadow: 0 0 0 0.25rem;\n}\n\n.navbar-toggler-icon {\n display: inline-block;\n width: 1.5em;\n height: 1.5em;\n vertical-align: middle;\n background-repeat: no-repeat;\n background-position: center;\n background-size: 100%;\n}\n\n.navbar-nav-scroll {\n max-height: var(--bs-scroll-height, 75vh);\n overflow-y: auto;\n}\n\n@media (min-width: 576px) {\n .navbar-expand-sm {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-sm .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-sm .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-sm .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-sm .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-sm .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-sm .navbar-toggler {\n display: none;\n }\n .navbar-expand-sm .offcanvas-header {\n display: none;\n }\n .navbar-expand-sm .offcanvas {\n position: inherit;\n bottom: 0;\n z-index: 1000;\n flex-grow: 1;\n visibility: visible !important;\n background-color: transparent;\n border-right: 0;\n border-left: 0;\n transition: none;\n transform: none;\n }\n .navbar-expand-sm .offcanvas-top,\n.navbar-expand-sm .offcanvas-bottom {\n height: auto;\n border-top: 0;\n border-bottom: 0;\n }\n .navbar-expand-sm .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 768px) {\n .navbar-expand-md {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-md .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-md .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-md .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-md .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-md .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-md .navbar-toggler {\n display: none;\n }\n .navbar-expand-md .offcanvas-header {\n display: none;\n }\n .navbar-expand-md .offcanvas {\n position: inherit;\n bottom: 0;\n z-index: 1000;\n flex-grow: 1;\n visibility: visible !important;\n background-color: transparent;\n border-right: 0;\n border-left: 0;\n transition: none;\n transform: none;\n }\n .navbar-expand-md .offcanvas-top,\n.navbar-expand-md .offcanvas-bottom {\n height: auto;\n border-top: 0;\n border-bottom: 0;\n }\n .navbar-expand-md .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 992px) {\n .navbar-expand-lg {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-lg .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-lg .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-lg .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-lg .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-lg .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-lg .navbar-toggler {\n display: none;\n }\n .navbar-expand-lg .offcanvas-header {\n display: none;\n }\n .navbar-expand-lg .offcanvas {\n position: inherit;\n bottom: 0;\n z-index: 1000;\n flex-grow: 1;\n visibility: visible !important;\n background-color: transparent;\n border-right: 0;\n border-left: 0;\n transition: none;\n transform: none;\n }\n .navbar-expand-lg .offcanvas-top,\n.navbar-expand-lg .offcanvas-bottom {\n height: auto;\n border-top: 0;\n border-bottom: 0;\n }\n .navbar-expand-lg .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 1200px) {\n .navbar-expand-xl {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-xl .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-xl .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-xl .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-xl .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-xl .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-xl .navbar-toggler {\n display: none;\n }\n .navbar-expand-xl .offcanvas-header {\n display: none;\n }\n .navbar-expand-xl .offcanvas {\n position: inherit;\n bottom: 0;\n z-index: 1000;\n flex-grow: 1;\n visibility: visible !important;\n background-color: transparent;\n border-right: 0;\n border-left: 0;\n transition: none;\n transform: none;\n }\n .navbar-expand-xl .offcanvas-top,\n.navbar-expand-xl .offcanvas-bottom {\n height: auto;\n border-top: 0;\n border-bottom: 0;\n }\n .navbar-expand-xl .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n@media (min-width: 1400px) {\n .navbar-expand-xxl {\n flex-wrap: nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-xxl .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-xxl .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-xxl .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-xxl .navbar-nav-scroll {\n overflow: visible;\n }\n .navbar-expand-xxl .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-xxl .navbar-toggler {\n display: none;\n }\n .navbar-expand-xxl .offcanvas-header {\n display: none;\n }\n .navbar-expand-xxl .offcanvas {\n position: inherit;\n bottom: 0;\n z-index: 1000;\n flex-grow: 1;\n visibility: visible !important;\n background-color: transparent;\n border-right: 0;\n border-left: 0;\n transition: none;\n transform: none;\n }\n .navbar-expand-xxl .offcanvas-top,\n.navbar-expand-xxl .offcanvas-bottom {\n height: auto;\n border-top: 0;\n border-bottom: 0;\n }\n .navbar-expand-xxl .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n}\n.navbar-expand {\n flex-wrap: nowrap;\n justify-content: flex-start;\n}\n.navbar-expand .navbar-nav {\n flex-direction: row;\n}\n.navbar-expand .navbar-nav .dropdown-menu {\n position: absolute;\n}\n.navbar-expand .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n}\n.navbar-expand .navbar-nav-scroll {\n overflow: visible;\n}\n.navbar-expand .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n}\n.navbar-expand .navbar-toggler {\n display: none;\n}\n.navbar-expand .offcanvas-header {\n display: none;\n}\n.navbar-expand .offcanvas {\n position: inherit;\n bottom: 0;\n z-index: 1000;\n flex-grow: 1;\n visibility: visible !important;\n background-color: transparent;\n border-right: 0;\n border-left: 0;\n transition: none;\n transform: none;\n}\n.navbar-expand .offcanvas-top,\n.navbar-expand .offcanvas-bottom {\n height: auto;\n border-top: 0;\n border-bottom: 0;\n}\n.navbar-expand .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n}\n\n.navbar-light .navbar-brand {\n color: rgba(0, 0, 0, 0.9);\n}\n.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n.navbar-light .navbar-nav .nav-link {\n color: rgba(0, 0, 0, 0.55);\n}\n.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus {\n color: rgba(0, 0, 0, 0.7);\n}\n.navbar-light .navbar-nav .nav-link.disabled {\n color: rgba(0, 0, 0, 0.3);\n}\n.navbar-light .navbar-nav .show > .nav-link,\n.navbar-light .navbar-nav .nav-link.active {\n color: rgba(0, 0, 0, 0.9);\n}\n.navbar-light .navbar-toggler {\n color: rgba(0, 0, 0, 0.55);\n border-color: rgba(0, 0, 0, 0.1);\n}\n.navbar-light .navbar-toggler-icon {\n 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\");\n}\n.navbar-light .navbar-text {\n color: rgba(0, 0, 0, 0.55);\n}\n.navbar-light .navbar-text a,\n.navbar-light .navbar-text a:hover,\n.navbar-light .navbar-text a:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-dark .navbar-brand {\n color: #fff;\n}\n.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus {\n color: #fff;\n}\n.navbar-dark .navbar-nav .nav-link {\n color: rgba(255, 255, 255, 0.55);\n}\n.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus {\n color: rgba(255, 255, 255, 0.75);\n}\n.navbar-dark .navbar-nav .nav-link.disabled {\n color: rgba(255, 255, 255, 0.25);\n}\n.navbar-dark .navbar-nav .show > .nav-link,\n.navbar-dark .navbar-nav .nav-link.active {\n color: #fff;\n}\n.navbar-dark .navbar-toggler {\n color: rgba(255, 255, 255, 0.55);\n border-color: rgba(255, 255, 255, 0.1);\n}\n.navbar-dark .navbar-toggler-icon {\n 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\");\n}\n.navbar-dark .navbar-text {\n color: rgba(255, 255, 255, 0.55);\n}\n.navbar-dark .navbar-text a,\n.navbar-dark .navbar-text a:hover,\n.navbar-dark .navbar-text a:focus {\n color: #fff;\n}\n\n.card {\n position: relative;\n display: flex;\n flex-direction: column;\n min-width: 0;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: border-box;\n border: 1px solid rgba(0, 0, 0, 0.125);\n border-radius: 0.25rem;\n}\n.card > hr {\n margin-right: 0;\n margin-left: 0;\n}\n.card > .list-group {\n border-top: inherit;\n border-bottom: inherit;\n}\n.card > .list-group:first-child {\n border-top-width: 0;\n border-top-left-radius: calc(0.25rem - 1px);\n border-top-right-radius: calc(0.25rem - 1px);\n}\n.card > .list-group:last-child {\n border-bottom-width: 0;\n border-bottom-right-radius: calc(0.25rem - 1px);\n border-bottom-left-radius: calc(0.25rem - 1px);\n}\n.card > .card-header + .list-group,\n.card > .list-group + .card-footer {\n border-top: 0;\n}\n\n.card-body {\n flex: 1 1 auto;\n padding: 1rem 1rem;\n}\n\n.card-title {\n margin-bottom: 0.5rem;\n}\n\n.card-subtitle {\n margin-top: -0.25rem;\n margin-bottom: 0;\n}\n\n.card-text:last-child {\n margin-bottom: 0;\n}\n\n.card-link + .card-link {\n margin-left: 1rem;\n}\n\n.card-header {\n padding: 0.5rem 1rem;\n margin-bottom: 0;\n background-color: rgba(0, 0, 0, 0.03);\n border-bottom: 1px solid rgba(0, 0, 0, 0.125);\n}\n.card-header:first-child {\n border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0;\n}\n\n.card-footer {\n padding: 0.5rem 1rem;\n background-color: rgba(0, 0, 0, 0.03);\n border-top: 1px solid rgba(0, 0, 0, 0.125);\n}\n.card-footer:last-child {\n border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px);\n}\n\n.card-header-tabs {\n margin-right: -0.5rem;\n margin-bottom: -0.5rem;\n margin-left: -0.5rem;\n border-bottom: 0;\n}\n\n.card-header-pills {\n margin-right: -0.5rem;\n margin-left: -0.5rem;\n}\n\n.card-img-overlay {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n padding: 1rem;\n border-radius: calc(0.25rem - 1px);\n}\n\n.card-img,\n.card-img-top,\n.card-img-bottom {\n width: 100%;\n}\n\n.card-img,\n.card-img-top {\n border-top-left-radius: calc(0.25rem - 1px);\n border-top-right-radius: calc(0.25rem - 1px);\n}\n\n.card-img,\n.card-img-bottom {\n border-bottom-right-radius: calc(0.25rem - 1px);\n border-bottom-left-radius: calc(0.25rem - 1px);\n}\n\n.card-group > .card {\n margin-bottom: 0.75rem;\n}\n@media (min-width: 576px) {\n .card-group {\n display: flex;\n flex-flow: row wrap;\n }\n .card-group > .card {\n flex: 1 0 0%;\n margin-bottom: 0;\n }\n .card-group > .card + .card {\n margin-left: 0;\n border-left: 0;\n }\n .card-group > .card:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-top,\n.card-group > .card:not(:last-child) .card-header {\n border-top-right-radius: 0;\n }\n .card-group > .card:not(:last-child) .card-img-bottom,\n.card-group > .card:not(:last-child) .card-footer {\n border-bottom-right-radius: 0;\n }\n .card-group > .card:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-top,\n.card-group > .card:not(:first-child) .card-header {\n border-top-left-radius: 0;\n }\n .card-group > .card:not(:first-child) .card-img-bottom,\n.card-group > .card:not(:first-child) .card-footer {\n border-bottom-left-radius: 0;\n }\n}\n\n.accordion-button {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n padding: 1rem 1.25rem;\n font-size: 1rem;\n color: #212529;\n text-align: left;\n background-color: #fff;\n border: 0;\n border-radius: 0;\n overflow-anchor: none;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .accordion-button {\n transition: none;\n }\n}\n.accordion-button:not(.collapsed) {\n color: #0c63e4;\n background-color: #e7f1ff;\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.125);\n}\n.accordion-button:not(.collapsed)::after {\n 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\");\n transform: rotate(-180deg);\n}\n.accordion-button::after {\n flex-shrink: 0;\n width: 1.25rem;\n height: 1.25rem;\n margin-left: auto;\n content: \"\";\n 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\");\n background-repeat: no-repeat;\n background-size: 1.25rem;\n transition: transform 0.2s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .accordion-button::after {\n transition: none;\n }\n}\n.accordion-button:hover {\n z-index: 2;\n}\n.accordion-button:focus {\n z-index: 3;\n border-color: #86b7fe;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n\n.accordion-header {\n margin-bottom: 0;\n}\n\n.accordion-item {\n background-color: #fff;\n border: 1px solid rgba(0, 0, 0, 0.125);\n}\n.accordion-item:first-of-type {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n.accordion-item:first-of-type .accordion-button {\n border-top-left-radius: calc(0.25rem - 1px);\n border-top-right-radius: calc(0.25rem - 1px);\n}\n.accordion-item:not(:first-of-type) {\n border-top: 0;\n}\n.accordion-item:last-of-type {\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n.accordion-item:last-of-type .accordion-button.collapsed {\n border-bottom-right-radius: calc(0.25rem - 1px);\n border-bottom-left-radius: calc(0.25rem - 1px);\n}\n.accordion-item:last-of-type .accordion-collapse {\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.accordion-body {\n padding: 1rem 1.25rem;\n}\n\n.accordion-flush .accordion-collapse {\n border-width: 0;\n}\n.accordion-flush .accordion-item {\n border-right: 0;\n border-left: 0;\n border-radius: 0;\n}\n.accordion-flush .accordion-item:first-child {\n border-top: 0;\n}\n.accordion-flush .accordion-item:last-child {\n border-bottom: 0;\n}\n.accordion-flush .accordion-item .accordion-button {\n border-radius: 0;\n}\n\n.breadcrumb {\n display: flex;\n flex-wrap: wrap;\n padding: 0 0;\n margin-bottom: 1rem;\n list-style: none;\n}\n\n.breadcrumb-item + .breadcrumb-item {\n padding-left: 0.5rem;\n}\n.breadcrumb-item + .breadcrumb-item::before {\n float: left;\n padding-right: 0.5rem;\n color: #6c757d;\n content: var(--bs-breadcrumb-divider, \"/\") /* rtl: var(--bs-breadcrumb-divider, \"/\") */;\n}\n.breadcrumb-item.active {\n color: #6c757d;\n}\n\n.pagination {\n display: flex;\n padding-left: 0;\n list-style: none;\n}\n\n.page-link {\n position: relative;\n display: block;\n color: #0d6efd;\n text-decoration: none;\n background-color: #fff;\n border: 1px solid #dee2e6;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .page-link {\n transition: none;\n }\n}\n.page-link:hover {\n z-index: 2;\n color: #0a58ca;\n background-color: #e9ecef;\n border-color: #dee2e6;\n}\n.page-link:focus {\n z-index: 3;\n color: #0a58ca;\n background-color: #e9ecef;\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n}\n\n.page-item:not(:first-child) .page-link {\n margin-left: -1px;\n}\n.page-item.active .page-link {\n z-index: 3;\n color: #fff;\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n.page-item.disabled .page-link {\n color: #6c757d;\n pointer-events: none;\n background-color: #fff;\n border-color: #dee2e6;\n}\n\n.page-link {\n padding: 0.375rem 0.75rem;\n}\n\n.page-item:first-child .page-link {\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n.page-item:last-child .page-link {\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n}\n\n.pagination-lg .page-link {\n padding: 0.75rem 1.5rem;\n font-size: 1.25rem;\n}\n.pagination-lg .page-item:first-child .page-link {\n border-top-left-radius: 0.3rem;\n border-bottom-left-radius: 0.3rem;\n}\n.pagination-lg .page-item:last-child .page-link {\n border-top-right-radius: 0.3rem;\n border-bottom-right-radius: 0.3rem;\n}\n\n.pagination-sm .page-link {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n}\n.pagination-sm .page-item:first-child .page-link {\n border-top-left-radius: 0.2rem;\n border-bottom-left-radius: 0.2rem;\n}\n.pagination-sm .page-item:last-child .page-link {\n border-top-right-radius: 0.2rem;\n border-bottom-right-radius: 0.2rem;\n}\n\n.badge {\n display: inline-block;\n padding: 0.35em 0.65em;\n font-size: 0.75em;\n font-weight: 700;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: 0.25rem;\n}\n.badge:empty {\n display: none;\n}\n\n.btn .badge {\n position: relative;\n top: -1px;\n}\n\n.alert {\n position: relative;\n padding: 1rem 1rem;\n margin-bottom: 1rem;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n}\n\n.alert-heading {\n color: inherit;\n}\n\n.alert-link {\n font-weight: 700;\n}\n\n.alert-dismissible {\n padding-right: 3rem;\n}\n.alert-dismissible .btn-close {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n padding: 1.25rem 1rem;\n}\n\n.alert-primary {\n color: #084298;\n background-color: #cfe2ff;\n border-color: #b6d4fe;\n}\n.alert-primary .alert-link {\n color: #06357a;\n}\n\n.alert-secondary {\n color: #41464b;\n background-color: #e2e3e5;\n border-color: #d3d6d8;\n}\n.alert-secondary .alert-link {\n color: #34383c;\n}\n\n.alert-success {\n color: #0f5132;\n background-color: #d1e7dd;\n border-color: #badbcc;\n}\n.alert-success .alert-link {\n color: #0c4128;\n}\n\n.alert-info {\n color: #055160;\n background-color: #cff4fc;\n border-color: #b6effb;\n}\n.alert-info .alert-link {\n color: #04414d;\n}\n\n.alert-warning {\n color: #664d03;\n background-color: #fff3cd;\n border-color: #ffecb5;\n}\n.alert-warning .alert-link {\n color: #523e02;\n}\n\n.alert-danger {\n color: #842029;\n background-color: #f8d7da;\n border-color: #f5c2c7;\n}\n.alert-danger .alert-link {\n color: #6a1a21;\n}\n\n.alert-light {\n color: #636464;\n background-color: #fefefe;\n border-color: #fdfdfe;\n}\n.alert-light .alert-link {\n color: #4f5050;\n}\n\n.alert-dark {\n color: #141619;\n background-color: #d3d3d4;\n border-color: #bcbebf;\n}\n.alert-dark .alert-link {\n color: #101214;\n}\n\n@-webkit-keyframes progress-bar-stripes {\n 0% {\n background-position-x: 1rem;\n }\n}\n\n@keyframes progress-bar-stripes {\n 0% {\n background-position-x: 1rem;\n }\n}\n.progress {\n display: flex;\n height: 1rem;\n overflow: hidden;\n font-size: 0.75rem;\n background-color: #e9ecef;\n border-radius: 0.25rem;\n}\n\n.progress-bar {\n display: flex;\n flex-direction: column;\n justify-content: center;\n overflow: hidden;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n background-color: #0d6efd;\n transition: width 0.6s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .progress-bar {\n transition: none;\n }\n}\n\n.progress-bar-striped {\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 1rem 1rem;\n}\n\n.progress-bar-animated {\n -webkit-animation: 1s linear infinite progress-bar-stripes;\n animation: 1s linear infinite progress-bar-stripes;\n}\n@media (prefers-reduced-motion: reduce) {\n .progress-bar-animated {\n -webkit-animation: none;\n animation: none;\n }\n}\n\n.list-group {\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n border-radius: 0.25rem;\n}\n\n.list-group-numbered {\n list-style-type: none;\n counter-reset: section;\n}\n.list-group-numbered > li::before {\n content: counters(section, \".\") \". \";\n counter-increment: section;\n}\n\n.list-group-item-action {\n width: 100%;\n color: #495057;\n text-align: inherit;\n}\n.list-group-item-action:hover, .list-group-item-action:focus {\n z-index: 1;\n color: #495057;\n text-decoration: none;\n background-color: #f8f9fa;\n}\n.list-group-item-action:active {\n color: #212529;\n background-color: #e9ecef;\n}\n\n.list-group-item {\n position: relative;\n display: block;\n padding: 0.5rem 1rem;\n color: #212529;\n text-decoration: none;\n background-color: #fff;\n border: 1px solid rgba(0, 0, 0, 0.125);\n}\n.list-group-item:first-child {\n border-top-left-radius: inherit;\n border-top-right-radius: inherit;\n}\n.list-group-item:last-child {\n border-bottom-right-radius: inherit;\n border-bottom-left-radius: inherit;\n}\n.list-group-item.disabled, .list-group-item:disabled {\n color: #6c757d;\n pointer-events: none;\n background-color: #fff;\n}\n.list-group-item.active {\n z-index: 2;\n color: #fff;\n background-color: #0d6efd;\n border-color: #0d6efd;\n}\n.list-group-item + .list-group-item {\n border-top-width: 0;\n}\n.list-group-item + .list-group-item.active {\n margin-top: -1px;\n border-top-width: 1px;\n}\n\n.list-group-horizontal {\n flex-direction: row;\n}\n.list-group-horizontal > .list-group-item:first-child {\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n}\n.list-group-horizontal > .list-group-item:last-child {\n border-top-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n}\n.list-group-horizontal > .list-group-item.active {\n margin-top: 0;\n}\n.list-group-horizontal > .list-group-item + .list-group-item {\n border-top-width: 1px;\n border-left-width: 0;\n}\n.list-group-horizontal > .list-group-item + .list-group-item.active {\n margin-left: -1px;\n border-left-width: 1px;\n}\n\n@media (min-width: 576px) {\n .list-group-horizontal-sm {\n flex-direction: row;\n }\n .list-group-horizontal-sm > .list-group-item:first-child {\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-sm > .list-group-item:last-child {\n border-top-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-sm > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-sm > .list-group-item + .list-group-item {\n border-top-width: 1px;\n border-left-width: 0;\n }\n .list-group-horizontal-sm > .list-group-item + .list-group-item.active {\n margin-left: -1px;\n border-left-width: 1px;\n }\n}\n@media (min-width: 768px) {\n .list-group-horizontal-md {\n flex-direction: row;\n }\n .list-group-horizontal-md > .list-group-item:first-child {\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-md > .list-group-item:last-child {\n border-top-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-md > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-md > .list-group-item + .list-group-item {\n border-top-width: 1px;\n border-left-width: 0;\n }\n .list-group-horizontal-md > .list-group-item + .list-group-item.active {\n margin-left: -1px;\n border-left-width: 1px;\n }\n}\n@media (min-width: 992px) {\n .list-group-horizontal-lg {\n flex-direction: row;\n }\n .list-group-horizontal-lg > .list-group-item:first-child {\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-lg > .list-group-item:last-child {\n border-top-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-lg > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-lg > .list-group-item + .list-group-item {\n border-top-width: 1px;\n border-left-width: 0;\n }\n .list-group-horizontal-lg > .list-group-item + .list-group-item.active {\n margin-left: -1px;\n border-left-width: 1px;\n }\n}\n@media (min-width: 1200px) {\n .list-group-horizontal-xl {\n flex-direction: row;\n }\n .list-group-horizontal-xl > .list-group-item:first-child {\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-xl > .list-group-item:last-child {\n border-top-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-xl > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-xl > .list-group-item + .list-group-item {\n border-top-width: 1px;\n border-left-width: 0;\n }\n .list-group-horizontal-xl > .list-group-item + .list-group-item.active {\n margin-left: -1px;\n border-left-width: 1px;\n }\n}\n@media (min-width: 1400px) {\n .list-group-horizontal-xxl {\n flex-direction: row;\n }\n .list-group-horizontal-xxl > .list-group-item:first-child {\n border-bottom-left-radius: 0.25rem;\n border-top-right-radius: 0;\n }\n .list-group-horizontal-xxl > .list-group-item:last-child {\n border-top-right-radius: 0.25rem;\n border-bottom-left-radius: 0;\n }\n .list-group-horizontal-xxl > .list-group-item.active {\n margin-top: 0;\n }\n .list-group-horizontal-xxl > .list-group-item + .list-group-item {\n border-top-width: 1px;\n border-left-width: 0;\n }\n .list-group-horizontal-xxl > .list-group-item + .list-group-item.active {\n margin-left: -1px;\n border-left-width: 1px;\n }\n}\n.list-group-flush {\n border-radius: 0;\n}\n.list-group-flush > .list-group-item {\n border-width: 0 0 1px;\n}\n.list-group-flush > .list-group-item:last-child {\n border-bottom-width: 0;\n}\n\n.list-group-item-primary {\n color: #084298;\n background-color: #cfe2ff;\n}\n.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus {\n color: #084298;\n background-color: #bacbe6;\n}\n.list-group-item-primary.list-group-item-action.active {\n color: #fff;\n background-color: #084298;\n border-color: #084298;\n}\n\n.list-group-item-secondary {\n color: #41464b;\n background-color: #e2e3e5;\n}\n.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus {\n color: #41464b;\n background-color: #cbccce;\n}\n.list-group-item-secondary.list-group-item-action.active {\n color: #fff;\n background-color: #41464b;\n border-color: #41464b;\n}\n\n.list-group-item-success {\n color: #0f5132;\n background-color: #d1e7dd;\n}\n.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus {\n color: #0f5132;\n background-color: #bcd0c7;\n}\n.list-group-item-success.list-group-item-action.active {\n color: #fff;\n background-color: #0f5132;\n border-color: #0f5132;\n}\n\n.list-group-item-info {\n color: #055160;\n background-color: #cff4fc;\n}\n.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus {\n color: #055160;\n background-color: #badce3;\n}\n.list-group-item-info.list-group-item-action.active {\n color: #fff;\n background-color: #055160;\n border-color: #055160;\n}\n\n.list-group-item-warning {\n color: #664d03;\n background-color: #fff3cd;\n}\n.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus {\n color: #664d03;\n background-color: #e6dbb9;\n}\n.list-group-item-warning.list-group-item-action.active {\n color: #fff;\n background-color: #664d03;\n border-color: #664d03;\n}\n\n.list-group-item-danger {\n color: #842029;\n background-color: #f8d7da;\n}\n.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus {\n color: #842029;\n background-color: #dfc2c4;\n}\n.list-group-item-danger.list-group-item-action.active {\n color: #fff;\n background-color: #842029;\n border-color: #842029;\n}\n\n.list-group-item-light {\n color: #636464;\n background-color: #fefefe;\n}\n.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus {\n color: #636464;\n background-color: #e5e5e5;\n}\n.list-group-item-light.list-group-item-action.active {\n color: #fff;\n background-color: #636464;\n border-color: #636464;\n}\n\n.list-group-item-dark {\n color: #141619;\n background-color: #d3d3d4;\n}\n.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus {\n color: #141619;\n background-color: #bebebf;\n}\n.list-group-item-dark.list-group-item-action.active {\n color: #fff;\n background-color: #141619;\n border-color: #141619;\n}\n\n.btn-close {\n box-sizing: content-box;\n width: 1em;\n height: 1em;\n padding: 0.25em 0.25em;\n color: #000;\n 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;\n border: 0;\n border-radius: 0.25rem;\n opacity: 0.5;\n}\n.btn-close:hover {\n color: #000;\n text-decoration: none;\n opacity: 0.75;\n}\n.btn-close:focus {\n outline: 0;\n box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);\n opacity: 1;\n}\n.btn-close:disabled, .btn-close.disabled {\n pointer-events: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n opacity: 0.25;\n}\n\n.btn-close-white {\n filter: invert(1) grayscale(100%) brightness(200%);\n}\n\n.toast {\n width: 350px;\n max-width: 100%;\n font-size: 0.875rem;\n pointer-events: auto;\n background-color: rgba(255, 255, 255, 0.85);\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.1);\n box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n border-radius: 0.25rem;\n}\n.toast.showing {\n opacity: 0;\n}\n.toast:not(.show) {\n display: none;\n}\n\n.toast-container {\n width: -webkit-max-content;\n width: -moz-max-content;\n width: max-content;\n max-width: 100%;\n pointer-events: none;\n}\n.toast-container > :not(:last-child) {\n margin-bottom: 0.75rem;\n}\n\n.toast-header {\n display: flex;\n align-items: center;\n padding: 0.5rem 0.75rem;\n color: #6c757d;\n background-color: rgba(255, 255, 255, 0.85);\n background-clip: padding-box;\n border-bottom: 1px solid rgba(0, 0, 0, 0.05);\n border-top-left-radius: calc(0.25rem - 1px);\n border-top-right-radius: calc(0.25rem - 1px);\n}\n.toast-header .btn-close {\n margin-right: -0.375rem;\n margin-left: 0.75rem;\n}\n\n.toast-body {\n padding: 0.75rem;\n word-wrap: break-word;\n}\n\n.modal {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1055;\n display: none;\n width: 100%;\n height: 100%;\n overflow-x: hidden;\n overflow-y: auto;\n outline: 0;\n}\n\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 0.5rem;\n pointer-events: none;\n}\n.modal.fade .modal-dialog {\n transition: transform 0.3s ease-out;\n transform: translate(0, -50px);\n}\n@media (prefers-reduced-motion: reduce) {\n .modal.fade .modal-dialog {\n transition: none;\n }\n}\n.modal.show .modal-dialog {\n transform: none;\n}\n.modal.modal-static .modal-dialog {\n transform: scale(1.02);\n}\n\n.modal-dialog-scrollable {\n height: calc(100% - 1rem);\n}\n.modal-dialog-scrollable .modal-content {\n max-height: 100%;\n overflow: hidden;\n}\n.modal-dialog-scrollable .modal-body {\n overflow-y: auto;\n}\n\n.modal-dialog-centered {\n display: flex;\n align-items: center;\n min-height: calc(100% - 1rem);\n}\n\n.modal-content {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n pointer-events: auto;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n outline: 0;\n}\n\n.modal-backdrop {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1050;\n width: 100vw;\n height: 100vh;\n background-color: #000;\n}\n.modal-backdrop.fade {\n opacity: 0;\n}\n.modal-backdrop.show {\n opacity: 0.5;\n}\n\n.modal-header {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n justify-content: space-between;\n padding: 1rem 1rem;\n border-bottom: 1px solid #dee2e6;\n border-top-left-radius: calc(0.3rem - 1px);\n border-top-right-radius: calc(0.3rem - 1px);\n}\n.modal-header .btn-close {\n padding: 0.5rem 0.5rem;\n margin: -0.5rem -0.5rem -0.5rem auto;\n}\n\n.modal-title {\n margin-bottom: 0;\n line-height: 1.5;\n}\n\n.modal-body {\n position: relative;\n flex: 1 1 auto;\n padding: 1rem;\n}\n\n.modal-footer {\n display: flex;\n flex-wrap: wrap;\n flex-shrink: 0;\n align-items: center;\n justify-content: flex-end;\n padding: 0.75rem;\n border-top: 1px solid #dee2e6;\n border-bottom-right-radius: calc(0.3rem - 1px);\n border-bottom-left-radius: calc(0.3rem - 1px);\n}\n.modal-footer > * {\n margin: 0.25rem;\n}\n\n@media (min-width: 576px) {\n .modal-dialog {\n max-width: 500px;\n margin: 1.75rem auto;\n }\n\n .modal-dialog-scrollable {\n height: calc(100% - 3.5rem);\n }\n\n .modal-dialog-centered {\n min-height: calc(100% - 3.5rem);\n }\n\n .modal-sm {\n max-width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg,\n.modal-xl {\n max-width: 800px;\n }\n}\n@media (min-width: 1200px) {\n .modal-xl {\n max-width: 1140px;\n }\n}\n.modal-fullscreen {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n}\n.modal-fullscreen .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n}\n.modal-fullscreen .modal-header {\n border-radius: 0;\n}\n.modal-fullscreen .modal-body {\n overflow-y: auto;\n}\n.modal-fullscreen .modal-footer {\n border-radius: 0;\n}\n\n@media (max-width: 575.98px) {\n .modal-fullscreen-sm-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-sm-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-sm-down .modal-header {\n border-radius: 0;\n }\n .modal-fullscreen-sm-down .modal-body {\n overflow-y: auto;\n }\n .modal-fullscreen-sm-down .modal-footer {\n border-radius: 0;\n }\n}\n@media (max-width: 767.98px) {\n .modal-fullscreen-md-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-md-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-md-down .modal-header {\n border-radius: 0;\n }\n .modal-fullscreen-md-down .modal-body {\n overflow-y: auto;\n }\n .modal-fullscreen-md-down .modal-footer {\n border-radius: 0;\n }\n}\n@media (max-width: 991.98px) {\n .modal-fullscreen-lg-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-lg-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-lg-down .modal-header {\n border-radius: 0;\n }\n .modal-fullscreen-lg-down .modal-body {\n overflow-y: auto;\n }\n .modal-fullscreen-lg-down .modal-footer {\n border-radius: 0;\n }\n}\n@media (max-width: 1199.98px) {\n .modal-fullscreen-xl-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-xl-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-xl-down .modal-header {\n border-radius: 0;\n }\n .modal-fullscreen-xl-down .modal-body {\n overflow-y: auto;\n }\n .modal-fullscreen-xl-down .modal-footer {\n border-radius: 0;\n }\n}\n@media (max-width: 1399.98px) {\n .modal-fullscreen-xxl-down {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n }\n .modal-fullscreen-xxl-down .modal-content {\n height: 100%;\n border: 0;\n border-radius: 0;\n }\n .modal-fullscreen-xxl-down .modal-header {\n border-radius: 0;\n }\n .modal-fullscreen-xxl-down .modal-body {\n overflow-y: auto;\n }\n .modal-fullscreen-xxl-down .modal-footer {\n border-radius: 0;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1080;\n display: block;\n margin: 0;\n font-family: var(--bs-font-sans-serif);\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n opacity: 0;\n}\n.tooltip.show {\n opacity: 0.9;\n}\n.tooltip .tooltip-arrow {\n position: absolute;\n display: block;\n width: 0.8rem;\n height: 0.4rem;\n}\n.tooltip .tooltip-arrow::before {\n position: absolute;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-tooltip-top, .bs-tooltip-auto[data-popper-placement^=top] {\n padding: 0.4rem 0;\n}\n.bs-tooltip-top .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow {\n bottom: 0;\n}\n.bs-tooltip-top .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before {\n top: -1px;\n border-width: 0.4rem 0.4rem 0;\n border-top-color: #000;\n}\n\n.bs-tooltip-end, .bs-tooltip-auto[data-popper-placement^=right] {\n padding: 0 0.4rem;\n}\n.bs-tooltip-end .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow {\n left: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n.bs-tooltip-end .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before {\n right: -1px;\n border-width: 0.4rem 0.4rem 0.4rem 0;\n border-right-color: #000;\n}\n\n.bs-tooltip-bottom, .bs-tooltip-auto[data-popper-placement^=bottom] {\n padding: 0.4rem 0;\n}\n.bs-tooltip-bottom .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow {\n top: 0;\n}\n.bs-tooltip-bottom .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before {\n bottom: -1px;\n border-width: 0 0.4rem 0.4rem;\n border-bottom-color: #000;\n}\n\n.bs-tooltip-start, .bs-tooltip-auto[data-popper-placement^=left] {\n padding: 0 0.4rem;\n}\n.bs-tooltip-start .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow {\n right: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n.bs-tooltip-start .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before {\n left: -1px;\n border-width: 0.4rem 0 0.4rem 0.4rem;\n border-left-color: #000;\n}\n\n.tooltip-inner {\n max-width: 200px;\n padding: 0.25rem 0.5rem;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 0.25rem;\n}\n\n.popover {\n position: absolute;\n top: 0;\n left: 0 /* rtl:ignore */;\n z-index: 1070;\n display: block;\n max-width: 276px;\n font-family: var(--bs-font-sans-serif);\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n}\n.popover .popover-arrow {\n position: absolute;\n display: block;\n width: 1rem;\n height: 0.5rem;\n}\n.popover .popover-arrow::before, .popover .popover-arrow::after {\n position: absolute;\n display: block;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-popover-top > .popover-arrow, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow {\n bottom: calc(-0.5rem - 1px);\n}\n.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before {\n bottom: 0;\n border-width: 0.5rem 0.5rem 0;\n border-top-color: rgba(0, 0, 0, 0.25);\n}\n.bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after {\n bottom: 1px;\n border-width: 0.5rem 0.5rem 0;\n border-top-color: #fff;\n}\n\n.bs-popover-end > .popover-arrow, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow {\n left: calc(-0.5rem - 1px);\n width: 0.5rem;\n height: 1rem;\n}\n.bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before {\n left: 0;\n border-width: 0.5rem 0.5rem 0.5rem 0;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n.bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after {\n left: 1px;\n border-width: 0.5rem 0.5rem 0.5rem 0;\n border-right-color: #fff;\n}\n\n.bs-popover-bottom > .popover-arrow, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow {\n top: calc(-0.5rem - 1px);\n}\n.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before {\n top: 0;\n border-width: 0 0.5rem 0.5rem 0.5rem;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n}\n.bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after {\n top: 1px;\n border-width: 0 0.5rem 0.5rem 0.5rem;\n border-bottom-color: #fff;\n}\n.bs-popover-bottom .popover-header::before, .bs-popover-auto[data-popper-placement^=bottom] .popover-header::before {\n position: absolute;\n top: 0;\n left: 50%;\n display: block;\n width: 1rem;\n margin-left: -0.5rem;\n content: \"\";\n border-bottom: 1px solid #f0f0f0;\n}\n\n.bs-popover-start > .popover-arrow, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow {\n right: calc(-0.5rem - 1px);\n width: 0.5rem;\n height: 1rem;\n}\n.bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before {\n right: 0;\n border-width: 0.5rem 0 0.5rem 0.5rem;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n.bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after {\n right: 1px;\n border-width: 0.5rem 0 0.5rem 0.5rem;\n border-left-color: #fff;\n}\n\n.popover-header {\n padding: 0.5rem 1rem;\n margin-bottom: 0;\n font-size: 1rem;\n background-color: #f0f0f0;\n border-bottom: 1px solid rgba(0, 0, 0, 0.2);\n border-top-left-radius: calc(0.3rem - 1px);\n border-top-right-radius: calc(0.3rem - 1px);\n}\n.popover-header:empty {\n display: none;\n}\n\n.popover-body {\n padding: 1rem 1rem;\n color: #212529;\n}\n\n.carousel {\n position: relative;\n}\n\n.carousel.pointer-event {\n touch-action: pan-y;\n}\n\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n.carousel-inner::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.carousel-item {\n position: relative;\n display: none;\n float: left;\n width: 100%;\n margin-right: -100%;\n -webkit-backface-visibility: hidden;\n backface-visibility: hidden;\n transition: transform 0.6s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-item {\n transition: none;\n }\n}\n\n.carousel-item.active,\n.carousel-item-next,\n.carousel-item-prev {\n display: block;\n}\n\n/* rtl:begin:ignore */\n.carousel-item-next:not(.carousel-item-start),\n.active.carousel-item-end {\n transform: translateX(100%);\n}\n\n.carousel-item-prev:not(.carousel-item-end),\n.active.carousel-item-start {\n transform: translateX(-100%);\n}\n\n/* rtl:end:ignore */\n.carousel-fade .carousel-item {\n opacity: 0;\n transition-property: opacity;\n transform: none;\n}\n.carousel-fade .carousel-item.active,\n.carousel-fade .carousel-item-next.carousel-item-start,\n.carousel-fade .carousel-item-prev.carousel-item-end {\n z-index: 1;\n opacity: 1;\n}\n.carousel-fade .active.carousel-item-start,\n.carousel-fade .active.carousel-item-end {\n z-index: 0;\n opacity: 0;\n transition: opacity 0s 0.6s;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-fade .active.carousel-item-start,\n.carousel-fade .active.carousel-item-end {\n transition: none;\n }\n}\n\n.carousel-control-prev,\n.carousel-control-next {\n position: absolute;\n top: 0;\n bottom: 0;\n z-index: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 15%;\n padding: 0;\n color: #fff;\n text-align: center;\n background: none;\n border: 0;\n opacity: 0.5;\n transition: opacity 0.15s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-control-prev,\n.carousel-control-next {\n transition: none;\n }\n}\n.carousel-control-prev:hover, .carousel-control-prev:focus,\n.carousel-control-next:hover,\n.carousel-control-next:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n opacity: 0.9;\n}\n\n.carousel-control-prev {\n left: 0;\n}\n\n.carousel-control-next {\n right: 0;\n}\n\n.carousel-control-prev-icon,\n.carousel-control-next-icon {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n background-repeat: no-repeat;\n background-position: 50%;\n background-size: 100% 100%;\n}\n\n/* rtl:options: {\n \"autoRename\": true,\n \"stringMap\":[ {\n \"name\" : \"prev-next\",\n \"search\" : \"prev\",\n \"replace\" : \"next\"\n } ]\n} */\n.carousel-control-prev-icon {\n 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\");\n}\n\n.carousel-control-next-icon {\n 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\");\n}\n\n.carousel-indicators {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 2;\n display: flex;\n justify-content: center;\n padding: 0;\n margin-right: 15%;\n margin-bottom: 1rem;\n margin-left: 15%;\n list-style: none;\n}\n.carousel-indicators [data-bs-target] {\n box-sizing: content-box;\n flex: 0 1 auto;\n width: 30px;\n height: 3px;\n padding: 0;\n margin-right: 3px;\n margin-left: 3px;\n text-indent: -999px;\n cursor: pointer;\n background-color: #fff;\n background-clip: padding-box;\n border: 0;\n border-top: 10px solid transparent;\n border-bottom: 10px solid transparent;\n opacity: 0.5;\n transition: opacity 0.6s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .carousel-indicators [data-bs-target] {\n transition: none;\n }\n}\n.carousel-indicators .active {\n opacity: 1;\n}\n\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 1.25rem;\n left: 15%;\n padding-top: 1.25rem;\n padding-bottom: 1.25rem;\n color: #fff;\n text-align: center;\n}\n\n.carousel-dark .carousel-control-prev-icon,\n.carousel-dark .carousel-control-next-icon {\n filter: invert(1) grayscale(100);\n}\n.carousel-dark .carousel-indicators [data-bs-target] {\n background-color: #000;\n}\n.carousel-dark .carousel-caption {\n color: #000;\n}\n\n@-webkit-keyframes spinner-border {\n to {\n transform: rotate(360deg) /* rtl:ignore */;\n }\n}\n\n@keyframes spinner-border {\n to {\n transform: rotate(360deg) /* rtl:ignore */;\n }\n}\n.spinner-border {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n vertical-align: -0.125em;\n border: 0.25em solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n -webkit-animation: 0.75s linear infinite spinner-border;\n animation: 0.75s linear infinite spinner-border;\n}\n\n.spinner-border-sm {\n width: 1rem;\n height: 1rem;\n border-width: 0.2em;\n}\n\n@-webkit-keyframes spinner-grow {\n 0% {\n transform: scale(0);\n }\n 50% {\n opacity: 1;\n transform: none;\n }\n}\n\n@keyframes spinner-grow {\n 0% {\n transform: scale(0);\n }\n 50% {\n opacity: 1;\n transform: none;\n }\n}\n.spinner-grow {\n display: inline-block;\n width: 2rem;\n height: 2rem;\n vertical-align: -0.125em;\n background-color: currentColor;\n border-radius: 50%;\n opacity: 0;\n -webkit-animation: 0.75s linear infinite spinner-grow;\n animation: 0.75s linear infinite spinner-grow;\n}\n\n.spinner-grow-sm {\n width: 1rem;\n height: 1rem;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .spinner-border,\n.spinner-grow {\n -webkit-animation-duration: 1.5s;\n animation-duration: 1.5s;\n }\n}\n.offcanvas {\n position: fixed;\n bottom: 0;\n z-index: 1045;\n display: flex;\n flex-direction: column;\n max-width: 100%;\n visibility: hidden;\n background-color: #fff;\n background-clip: padding-box;\n outline: 0;\n transition: transform 0.3s ease-in-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .offcanvas {\n transition: none;\n }\n}\n\n.offcanvas-backdrop {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 1040;\n width: 100vw;\n height: 100vh;\n background-color: #000;\n}\n.offcanvas-backdrop.fade {\n opacity: 0;\n}\n.offcanvas-backdrop.show {\n opacity: 0.5;\n}\n\n.offcanvas-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1rem 1rem;\n}\n.offcanvas-header .btn-close {\n padding: 0.5rem 0.5rem;\n margin-top: -0.5rem;\n margin-right: -0.5rem;\n margin-bottom: -0.5rem;\n}\n\n.offcanvas-title {\n margin-bottom: 0;\n line-height: 1.5;\n}\n\n.offcanvas-body {\n flex-grow: 1;\n padding: 1rem 1rem;\n overflow-y: auto;\n}\n\n.offcanvas-start {\n top: 0;\n left: 0;\n width: 400px;\n border-right: 1px solid rgba(0, 0, 0, 0.2);\n transform: translateX(-100%);\n}\n\n.offcanvas-end {\n top: 0;\n right: 0;\n width: 400px;\n border-left: 1px solid rgba(0, 0, 0, 0.2);\n transform: translateX(100%);\n}\n\n.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: 30vh;\n max-height: 100%;\n border-bottom: 1px solid rgba(0, 0, 0, 0.2);\n transform: translateY(-100%);\n}\n\n.offcanvas-bottom {\n right: 0;\n left: 0;\n height: 30vh;\n max-height: 100%;\n border-top: 1px solid rgba(0, 0, 0, 0.2);\n transform: translateY(100%);\n}\n\n.offcanvas.show {\n transform: none;\n}\n\n.placeholder {\n display: inline-block;\n min-height: 1em;\n vertical-align: middle;\n cursor: wait;\n background-color: currentColor;\n opacity: 0.5;\n}\n.placeholder.btn::before {\n display: inline-block;\n content: \"\";\n}\n\n.placeholder-xs {\n min-height: 0.6em;\n}\n\n.placeholder-sm {\n min-height: 0.8em;\n}\n\n.placeholder-lg {\n min-height: 1.2em;\n}\n\n.placeholder-glow .placeholder {\n -webkit-animation: placeholder-glow 2s ease-in-out infinite;\n animation: placeholder-glow 2s ease-in-out infinite;\n}\n\n@-webkit-keyframes placeholder-glow {\n 50% {\n opacity: 0.2;\n }\n}\n\n@keyframes placeholder-glow {\n 50% {\n opacity: 0.2;\n }\n}\n.placeholder-wave {\n -webkit-mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);\n mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%);\n -webkit-mask-size: 200% 100%;\n mask-size: 200% 100%;\n -webkit-animation: placeholder-wave 2s linear infinite;\n animation: placeholder-wave 2s linear infinite;\n}\n\n@-webkit-keyframes placeholder-wave {\n 100% {\n -webkit-mask-position: -200% 0%;\n mask-position: -200% 0%;\n }\n}\n\n@keyframes placeholder-wave {\n 100% {\n -webkit-mask-position: -200% 0%;\n mask-position: -200% 0%;\n }\n}\n.clearfix::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.link-primary {\n color: #0d6efd;\n}\n.link-primary:hover, .link-primary:focus {\n color: #0a58ca;\n}\n\n.link-secondary {\n color: #6c757d;\n}\n.link-secondary:hover, .link-secondary:focus {\n color: #565e64;\n}\n\n.link-success {\n color: #198754;\n}\n.link-success:hover, .link-success:focus {\n color: #146c43;\n}\n\n.link-info {\n color: #0dcaf0;\n}\n.link-info:hover, .link-info:focus {\n color: #3dd5f3;\n}\n\n.link-warning {\n color: #ffc107;\n}\n.link-warning:hover, .link-warning:focus {\n color: #ffcd39;\n}\n\n.link-danger {\n color: #dc3545;\n}\n.link-danger:hover, .link-danger:focus {\n color: #b02a37;\n}\n\n.link-light {\n color: #f8f9fa;\n}\n.link-light:hover, .link-light:focus {\n color: #f9fafb;\n}\n\n.link-dark {\n color: #212529;\n}\n.link-dark:hover, .link-dark:focus {\n color: #1a1e21;\n}\n\n.ratio {\n position: relative;\n width: 100%;\n}\n.ratio::before {\n display: block;\n padding-top: var(--bs-aspect-ratio);\n content: \"\";\n}\n.ratio > * {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n}\n\n.ratio-1x1 {\n --bs-aspect-ratio: 100%;\n}\n\n.ratio-4x3 {\n --bs-aspect-ratio: calc(3 / 4 * 100%);\n}\n\n.ratio-16x9 {\n --bs-aspect-ratio: calc(9 / 16 * 100%);\n}\n\n.ratio-21x9 {\n --bs-aspect-ratio: calc(9 / 21 * 100%);\n}\n\n.fixed-top {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n\n.fixed-bottom {\n position: fixed;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1030;\n}\n\n.sticky-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n}\n\n@media (min-width: 576px) {\n .sticky-sm-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 768px) {\n .sticky-md-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 992px) {\n .sticky-lg-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 1200px) {\n .sticky-xl-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n@media (min-width: 1400px) {\n .sticky-xxl-top {\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n.hstack {\n display: flex;\n flex-direction: row;\n align-items: center;\n align-self: stretch;\n}\n\n.vstack {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-self: stretch;\n}\n\n.visually-hidden,\n.visually-hidden-focusable:not(:focus):not(:focus-within) {\n position: absolute !important;\n width: 1px !important;\n height: 1px !important;\n padding: 0 !important;\n margin: -1px !important;\n overflow: hidden !important;\n clip: rect(0, 0, 0, 0) !important;\n white-space: nowrap !important;\n border: 0 !important;\n}\n\n.stretched-link::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1;\n content: \"\";\n}\n\n.text-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.vr {\n display: inline-block;\n align-self: stretch;\n width: 1px;\n min-height: 1em;\n background-color: currentColor;\n opacity: 0.25;\n}\n\n.align-baseline {\n vertical-align: baseline !important;\n}\n\n.align-top {\n vertical-align: top !important;\n}\n\n.align-middle {\n vertical-align: middle !important;\n}\n\n.align-bottom {\n vertical-align: bottom !important;\n}\n\n.align-text-bottom {\n vertical-align: text-bottom !important;\n}\n\n.align-text-top {\n vertical-align: text-top !important;\n}\n\n.float-start {\n float: left !important;\n}\n\n.float-end {\n float: right !important;\n}\n\n.float-none {\n float: none !important;\n}\n\n.opacity-0 {\n opacity: 0 !important;\n}\n\n.opacity-25 {\n opacity: 0.25 !important;\n}\n\n.opacity-50 {\n opacity: 0.5 !important;\n}\n\n.opacity-75 {\n opacity: 0.75 !important;\n}\n\n.opacity-100 {\n opacity: 1 !important;\n}\n\n.overflow-auto {\n overflow: auto !important;\n}\n\n.overflow-hidden {\n overflow: hidden !important;\n}\n\n.overflow-visible {\n overflow: visible !important;\n}\n\n.overflow-scroll {\n overflow: scroll !important;\n}\n\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.shadow {\n box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;\n}\n\n.shadow-sm {\n box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;\n}\n\n.shadow-lg {\n box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important;\n}\n\n.shadow-none {\n box-shadow: none !important;\n}\n\n.position-static {\n position: static !important;\n}\n\n.position-relative {\n position: relative !important;\n}\n\n.position-absolute {\n position: absolute !important;\n}\n\n.position-fixed {\n position: fixed !important;\n}\n\n.position-sticky {\n position: -webkit-sticky !important;\n position: sticky !important;\n}\n\n.top-0 {\n top: 0 !important;\n}\n\n.top-50 {\n top: 50% !important;\n}\n\n.top-100 {\n top: 100% !important;\n}\n\n.bottom-0 {\n bottom: 0 !important;\n}\n\n.bottom-50 {\n bottom: 50% !important;\n}\n\n.bottom-100 {\n bottom: 100% !important;\n}\n\n.start-0 {\n left: 0 !important;\n}\n\n.start-50 {\n left: 50% !important;\n}\n\n.start-100 {\n left: 100% !important;\n}\n\n.end-0 {\n right: 0 !important;\n}\n\n.end-50 {\n right: 50% !important;\n}\n\n.end-100 {\n right: 100% !important;\n}\n\n.translate-middle {\n transform: translate(-50%, -50%) !important;\n}\n\n.translate-middle-x {\n transform: translateX(-50%) !important;\n}\n\n.translate-middle-y {\n transform: translateY(-50%) !important;\n}\n\n.border {\n border: 1px solid #dee2e6 !important;\n}\n\n.border-0 {\n border: 0 !important;\n}\n\n.border-top {\n border-top: 1px solid #dee2e6 !important;\n}\n\n.border-top-0 {\n border-top: 0 !important;\n}\n\n.border-end {\n border-right: 1px solid #dee2e6 !important;\n}\n\n.border-end-0 {\n border-right: 0 !important;\n}\n\n.border-bottom {\n border-bottom: 1px solid #dee2e6 !important;\n}\n\n.border-bottom-0 {\n border-bottom: 0 !important;\n}\n\n.border-start {\n border-left: 1px solid #dee2e6 !important;\n}\n\n.border-start-0 {\n border-left: 0 !important;\n}\n\n.border-primary {\n border-color: #0d6efd !important;\n}\n\n.border-secondary {\n border-color: #6c757d !important;\n}\n\n.border-success {\n border-color: #198754 !important;\n}\n\n.border-info {\n border-color: #0dcaf0 !important;\n}\n\n.border-warning {\n border-color: #ffc107 !important;\n}\n\n.border-danger {\n border-color: #dc3545 !important;\n}\n\n.border-light {\n border-color: #f8f9fa !important;\n}\n\n.border-dark {\n border-color: #212529 !important;\n}\n\n.border-white {\n border-color: #fff !important;\n}\n\n.border-1 {\n border-width: 1px !important;\n}\n\n.border-2 {\n border-width: 2px !important;\n}\n\n.border-3 {\n border-width: 3px !important;\n}\n\n.border-4 {\n border-width: 4px !important;\n}\n\n.border-5 {\n border-width: 5px !important;\n}\n\n.w-25 {\n width: 25% !important;\n}\n\n.w-50 {\n width: 50% !important;\n}\n\n.w-75 {\n width: 75% !important;\n}\n\n.w-100 {\n width: 100% !important;\n}\n\n.w-auto {\n width: auto !important;\n}\n\n.mw-100 {\n max-width: 100% !important;\n}\n\n.vw-100 {\n width: 100vw !important;\n}\n\n.min-vw-100 {\n min-width: 100vw !important;\n}\n\n.h-25 {\n height: 25% !important;\n}\n\n.h-50 {\n height: 50% !important;\n}\n\n.h-75 {\n height: 75% !important;\n}\n\n.h-100 {\n height: 100% !important;\n}\n\n.h-auto {\n height: auto !important;\n}\n\n.mh-100 {\n max-height: 100% !important;\n}\n\n.vh-100 {\n height: 100vh !important;\n}\n\n.min-vh-100 {\n min-height: 100vh !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.gap-0 {\n gap: 0 !important;\n}\n\n.gap-1 {\n gap: 0.25rem !important;\n}\n\n.gap-2 {\n gap: 0.5rem !important;\n}\n\n.gap-3 {\n gap: 1rem !important;\n}\n\n.gap-4 {\n gap: 1.5rem !important;\n}\n\n.gap-5 {\n gap: 3rem !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n.font-monospace {\n font-family: var(--bs-font-monospace) !important;\n}\n\n.fs-1 {\n font-size: calc(1.375rem + 1.5vw) !important;\n}\n\n.fs-2 {\n font-size: calc(1.325rem + 0.9vw) !important;\n}\n\n.fs-3 {\n font-size: calc(1.3rem + 0.6vw) !important;\n}\n\n.fs-4 {\n font-size: calc(1.275rem + 0.3vw) !important;\n}\n\n.fs-5 {\n font-size: 1.25rem !important;\n}\n\n.fs-6 {\n font-size: 1rem !important;\n}\n\n.fst-italic {\n font-style: italic !important;\n}\n\n.fst-normal {\n font-style: normal !important;\n}\n\n.fw-light {\n font-weight: 300 !important;\n}\n\n.fw-lighter {\n font-weight: lighter !important;\n}\n\n.fw-normal {\n font-weight: 400 !important;\n}\n\n.fw-bold {\n font-weight: 700 !important;\n}\n\n.fw-bolder {\n font-weight: bolder !important;\n}\n\n.lh-1 {\n line-height: 1 !important;\n}\n\n.lh-sm {\n line-height: 1.25 !important;\n}\n\n.lh-base {\n line-height: 1.5 !important;\n}\n\n.lh-lg {\n line-height: 2 !important;\n}\n\n.text-start {\n text-align: left !important;\n}\n\n.text-end {\n text-align: right !important;\n}\n\n.text-center {\n text-align: center !important;\n}\n\n.text-decoration-none {\n text-decoration: none !important;\n}\n\n.text-decoration-underline {\n text-decoration: underline !important;\n}\n\n.text-decoration-line-through {\n text-decoration: line-through !important;\n}\n\n.text-lowercase {\n text-transform: lowercase !important;\n}\n\n.text-uppercase {\n text-transform: uppercase !important;\n}\n\n.text-capitalize {\n text-transform: capitalize !important;\n}\n\n.text-wrap {\n white-space: normal !important;\n}\n\n.text-nowrap {\n white-space: nowrap !important;\n}\n\n/* rtl:begin:remove */\n.text-break {\n word-wrap: break-word !important;\n word-break: break-word !important;\n}\n\n/* rtl:end:remove */\n.text-primary {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-secondary {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-success {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-info {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-warning {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-danger {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-light {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-dark {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-black {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-white {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-body {\n --bs-text-opacity: 1;\n color: rgba(var(--bs-body-rgb), var(--bs-text-opacity)) !important;\n}\n\n.text-muted {\n --bs-text-opacity: 1;\n color: #6c757d !important;\n}\n\n.text-black-50 {\n --bs-text-opacity: 1;\n color: rgba(0, 0, 0, 0.5) !important;\n}\n\n.text-white-50 {\n --bs-text-opacity: 1;\n color: rgba(255, 255, 255, 0.5) !important;\n}\n\n.text-reset {\n --bs-text-opacity: 1;\n color: inherit !important;\n}\n\n.text-opacity-25 {\n --bs-text-opacity: 0.25;\n}\n\n.text-opacity-50 {\n --bs-text-opacity: 0.5;\n}\n\n.text-opacity-75 {\n --bs-text-opacity: 0.75;\n}\n\n.text-opacity-100 {\n --bs-text-opacity: 1;\n}\n\n.bg-primary {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-secondary {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-secondary-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-success {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-success-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-info {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-warning {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-warning-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-danger {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-light {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-dark {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-black {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-white {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-body {\n --bs-bg-opacity: 1;\n background-color: rgba(var(--bs-body-rgb), var(--bs-bg-opacity)) !important;\n}\n\n.bg-transparent {\n --bs-bg-opacity: 1;\n background-color: transparent !important;\n}\n\n.bg-opacity-10 {\n --bs-bg-opacity: 0.1;\n}\n\n.bg-opacity-25 {\n --bs-bg-opacity: 0.25;\n}\n\n.bg-opacity-50 {\n --bs-bg-opacity: 0.5;\n}\n\n.bg-opacity-75 {\n --bs-bg-opacity: 0.75;\n}\n\n.bg-opacity-100 {\n --bs-bg-opacity: 1;\n}\n\n.bg-gradient {\n background-image: var(--bs-gradient) !important;\n}\n\n.user-select-all {\n -webkit-user-select: all !important;\n -moz-user-select: all !important;\n user-select: all !important;\n}\n\n.user-select-auto {\n -webkit-user-select: auto !important;\n -moz-user-select: auto !important;\n user-select: auto !important;\n}\n\n.user-select-none {\n -webkit-user-select: none !important;\n -moz-user-select: none !important;\n user-select: none !important;\n}\n\n.pe-none {\n pointer-events: none !important;\n}\n\n.pe-auto {\n pointer-events: auto !important;\n}\n\n.rounded {\n border-radius: 0.25rem !important;\n}\n\n.rounded-0 {\n border-radius: 0 !important;\n}\n\n.rounded-1 {\n border-radius: 0.2rem !important;\n}\n\n.rounded-2 {\n border-radius: 0.25rem !important;\n}\n\n.rounded-3 {\n border-radius: 0.3rem !important;\n}\n\n.rounded-circle {\n border-radius: 50% !important;\n}\n\n.rounded-pill {\n border-radius: 50rem !important;\n}\n\n.rounded-top {\n border-top-left-radius: 0.25rem !important;\n border-top-right-radius: 0.25rem !important;\n}\n\n.rounded-end {\n border-top-right-radius: 0.25rem !important;\n border-bottom-right-radius: 0.25rem !important;\n}\n\n.rounded-bottom {\n border-bottom-right-radius: 0.25rem !important;\n border-bottom-left-radius: 0.25rem !important;\n}\n\n.rounded-start {\n border-bottom-left-radius: 0.25rem !important;\n border-top-left-radius: 0.25rem !important;\n}\n\n.visible {\n visibility: visible !important;\n}\n\n.invisible {\n visibility: hidden !important;\n}\n\n@media (min-width: 576px) {\n .float-sm-start {\n float: left !important;\n }\n\n .float-sm-end {\n float: right !important;\n }\n\n .float-sm-none {\n float: none !important;\n }\n\n .d-sm-inline {\n display: inline !important;\n }\n\n .d-sm-inline-block {\n display: inline-block !important;\n }\n\n .d-sm-block {\n display: block !important;\n }\n\n .d-sm-grid {\n display: grid !important;\n }\n\n .d-sm-table {\n display: table !important;\n }\n\n .d-sm-table-row {\n display: table-row !important;\n }\n\n .d-sm-table-cell {\n display: table-cell !important;\n }\n\n .d-sm-flex {\n display: flex !important;\n }\n\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n\n .d-sm-none {\n display: none !important;\n }\n\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n\n .flex-sm-row {\n flex-direction: row !important;\n }\n\n .flex-sm-column {\n flex-direction: column !important;\n }\n\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n\n .gap-sm-0 {\n gap: 0 !important;\n }\n\n .gap-sm-1 {\n gap: 0.25rem !important;\n }\n\n .gap-sm-2 {\n gap: 0.5rem !important;\n }\n\n .gap-sm-3 {\n gap: 1rem !important;\n }\n\n .gap-sm-4 {\n gap: 1.5rem !important;\n }\n\n .gap-sm-5 {\n gap: 3rem !important;\n }\n\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n\n .justify-content-sm-center {\n justify-content: center !important;\n }\n\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n\n .align-items-sm-center {\n align-items: center !important;\n }\n\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n\n .align-content-sm-center {\n align-content: center !important;\n }\n\n .align-content-sm-between {\n align-content: space-between !important;\n }\n\n .align-content-sm-around {\n align-content: space-around !important;\n }\n\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n\n .align-self-sm-auto {\n align-self: auto !important;\n }\n\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n\n .align-self-sm-center {\n align-self: center !important;\n }\n\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n\n .order-sm-first {\n order: -1 !important;\n }\n\n .order-sm-0 {\n order: 0 !important;\n }\n\n .order-sm-1 {\n order: 1 !important;\n }\n\n .order-sm-2 {\n order: 2 !important;\n }\n\n .order-sm-3 {\n order: 3 !important;\n }\n\n .order-sm-4 {\n order: 4 !important;\n }\n\n .order-sm-5 {\n order: 5 !important;\n }\n\n .order-sm-last {\n order: 6 !important;\n }\n\n .m-sm-0 {\n margin: 0 !important;\n }\n\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n\n .m-sm-3 {\n margin: 1rem !important;\n }\n\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n\n .m-sm-5 {\n margin: 3rem !important;\n }\n\n .m-sm-auto {\n margin: auto !important;\n }\n\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n\n .mt-sm-auto {\n margin-top: auto !important;\n }\n\n .me-sm-0 {\n margin-right: 0 !important;\n }\n\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n\n .me-sm-auto {\n margin-right: auto !important;\n }\n\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n\n .ms-sm-auto {\n margin-left: auto !important;\n }\n\n .p-sm-0 {\n padding: 0 !important;\n }\n\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n\n .p-sm-3 {\n padding: 1rem !important;\n }\n\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n\n .p-sm-5 {\n padding: 3rem !important;\n }\n\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n\n .text-sm-start {\n text-align: left !important;\n }\n\n .text-sm-end {\n text-align: right !important;\n }\n\n .text-sm-center {\n text-align: center !important;\n }\n}\n@media (min-width: 768px) {\n .float-md-start {\n float: left !important;\n }\n\n .float-md-end {\n float: right !important;\n }\n\n .float-md-none {\n float: none !important;\n }\n\n .d-md-inline {\n display: inline !important;\n }\n\n .d-md-inline-block {\n display: inline-block !important;\n }\n\n .d-md-block {\n display: block !important;\n }\n\n .d-md-grid {\n display: grid !important;\n }\n\n .d-md-table {\n display: table !important;\n }\n\n .d-md-table-row {\n display: table-row !important;\n }\n\n .d-md-table-cell {\n display: table-cell !important;\n }\n\n .d-md-flex {\n display: flex !important;\n }\n\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n\n .d-md-none {\n display: none !important;\n }\n\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n\n .flex-md-row {\n flex-direction: row !important;\n }\n\n .flex-md-column {\n flex-direction: column !important;\n }\n\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n\n .gap-md-0 {\n gap: 0 !important;\n }\n\n .gap-md-1 {\n gap: 0.25rem !important;\n }\n\n .gap-md-2 {\n gap: 0.5rem !important;\n }\n\n .gap-md-3 {\n gap: 1rem !important;\n }\n\n .gap-md-4 {\n gap: 1.5rem !important;\n }\n\n .gap-md-5 {\n gap: 3rem !important;\n }\n\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n\n .justify-content-md-center {\n justify-content: center !important;\n }\n\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n\n .align-items-md-start {\n align-items: flex-start !important;\n }\n\n .align-items-md-end {\n align-items: flex-end !important;\n }\n\n .align-items-md-center {\n align-items: center !important;\n }\n\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n\n .align-content-md-start {\n align-content: flex-start !important;\n }\n\n .align-content-md-end {\n align-content: flex-end !important;\n }\n\n .align-content-md-center {\n align-content: center !important;\n }\n\n .align-content-md-between {\n align-content: space-between !important;\n }\n\n .align-content-md-around {\n align-content: space-around !important;\n }\n\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n\n .align-self-md-auto {\n align-self: auto !important;\n }\n\n .align-self-md-start {\n align-self: flex-start !important;\n }\n\n .align-self-md-end {\n align-self: flex-end !important;\n }\n\n .align-self-md-center {\n align-self: center !important;\n }\n\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n\n .order-md-first {\n order: -1 !important;\n }\n\n .order-md-0 {\n order: 0 !important;\n }\n\n .order-md-1 {\n order: 1 !important;\n }\n\n .order-md-2 {\n order: 2 !important;\n }\n\n .order-md-3 {\n order: 3 !important;\n }\n\n .order-md-4 {\n order: 4 !important;\n }\n\n .order-md-5 {\n order: 5 !important;\n }\n\n .order-md-last {\n order: 6 !important;\n }\n\n .m-md-0 {\n margin: 0 !important;\n }\n\n .m-md-1 {\n margin: 0.25rem !important;\n }\n\n .m-md-2 {\n margin: 0.5rem !important;\n }\n\n .m-md-3 {\n margin: 1rem !important;\n }\n\n .m-md-4 {\n margin: 1.5rem !important;\n }\n\n .m-md-5 {\n margin: 3rem !important;\n }\n\n .m-md-auto {\n margin: auto !important;\n }\n\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n\n .mt-md-0 {\n margin-top: 0 !important;\n }\n\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n\n .mt-md-auto {\n margin-top: auto !important;\n }\n\n .me-md-0 {\n margin-right: 0 !important;\n }\n\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n\n .me-md-3 {\n margin-right: 1rem !important;\n }\n\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n\n .me-md-5 {\n margin-right: 3rem !important;\n }\n\n .me-md-auto {\n margin-right: auto !important;\n }\n\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n\n .ms-md-0 {\n margin-left: 0 !important;\n }\n\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n\n .ms-md-auto {\n margin-left: auto !important;\n }\n\n .p-md-0 {\n padding: 0 !important;\n }\n\n .p-md-1 {\n padding: 0.25rem !important;\n }\n\n .p-md-2 {\n padding: 0.5rem !important;\n }\n\n .p-md-3 {\n padding: 1rem !important;\n }\n\n .p-md-4 {\n padding: 1.5rem !important;\n }\n\n .p-md-5 {\n padding: 3rem !important;\n }\n\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n\n .pt-md-0 {\n padding-top: 0 !important;\n }\n\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n\n .pe-md-0 {\n padding-right: 0 !important;\n }\n\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n\n .ps-md-0 {\n padding-left: 0 !important;\n }\n\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n\n .text-md-start {\n text-align: left !important;\n }\n\n .text-md-end {\n text-align: right !important;\n }\n\n .text-md-center {\n text-align: center !important;\n }\n}\n@media (min-width: 992px) {\n .float-lg-start {\n float: left !important;\n }\n\n .float-lg-end {\n float: right !important;\n }\n\n .float-lg-none {\n float: none !important;\n }\n\n .d-lg-inline {\n display: inline !important;\n }\n\n .d-lg-inline-block {\n display: inline-block !important;\n }\n\n .d-lg-block {\n display: block !important;\n }\n\n .d-lg-grid {\n display: grid !important;\n }\n\n .d-lg-table {\n display: table !important;\n }\n\n .d-lg-table-row {\n display: table-row !important;\n }\n\n .d-lg-table-cell {\n display: table-cell !important;\n }\n\n .d-lg-flex {\n display: flex !important;\n }\n\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n\n .d-lg-none {\n display: none !important;\n }\n\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n\n .flex-lg-row {\n flex-direction: row !important;\n }\n\n .flex-lg-column {\n flex-direction: column !important;\n }\n\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n\n .gap-lg-0 {\n gap: 0 !important;\n }\n\n .gap-lg-1 {\n gap: 0.25rem !important;\n }\n\n .gap-lg-2 {\n gap: 0.5rem !important;\n }\n\n .gap-lg-3 {\n gap: 1rem !important;\n }\n\n .gap-lg-4 {\n gap: 1.5rem !important;\n }\n\n .gap-lg-5 {\n gap: 3rem !important;\n }\n\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n\n .justify-content-lg-center {\n justify-content: center !important;\n }\n\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n\n .align-items-lg-center {\n align-items: center !important;\n }\n\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n\n .align-content-lg-center {\n align-content: center !important;\n }\n\n .align-content-lg-between {\n align-content: space-between !important;\n }\n\n .align-content-lg-around {\n align-content: space-around !important;\n }\n\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n\n .align-self-lg-auto {\n align-self: auto !important;\n }\n\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n\n .align-self-lg-center {\n align-self: center !important;\n }\n\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n\n .order-lg-first {\n order: -1 !important;\n }\n\n .order-lg-0 {\n order: 0 !important;\n }\n\n .order-lg-1 {\n order: 1 !important;\n }\n\n .order-lg-2 {\n order: 2 !important;\n }\n\n .order-lg-3 {\n order: 3 !important;\n }\n\n .order-lg-4 {\n order: 4 !important;\n }\n\n .order-lg-5 {\n order: 5 !important;\n }\n\n .order-lg-last {\n order: 6 !important;\n }\n\n .m-lg-0 {\n margin: 0 !important;\n }\n\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n\n .m-lg-3 {\n margin: 1rem !important;\n }\n\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n\n .m-lg-5 {\n margin: 3rem !important;\n }\n\n .m-lg-auto {\n margin: auto !important;\n }\n\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n\n .mt-lg-auto {\n margin-top: auto !important;\n }\n\n .me-lg-0 {\n margin-right: 0 !important;\n }\n\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n\n .me-lg-auto {\n margin-right: auto !important;\n }\n\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n\n .ms-lg-auto {\n margin-left: auto !important;\n }\n\n .p-lg-0 {\n padding: 0 !important;\n }\n\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n\n .p-lg-3 {\n padding: 1rem !important;\n }\n\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n\n .p-lg-5 {\n padding: 3rem !important;\n }\n\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n\n .text-lg-start {\n text-align: left !important;\n }\n\n .text-lg-end {\n text-align: right !important;\n }\n\n .text-lg-center {\n text-align: center !important;\n }\n}\n@media (min-width: 1200px) {\n .float-xl-start {\n float: left !important;\n }\n\n .float-xl-end {\n float: right !important;\n }\n\n .float-xl-none {\n float: none !important;\n }\n\n .d-xl-inline {\n display: inline !important;\n }\n\n .d-xl-inline-block {\n display: inline-block !important;\n }\n\n .d-xl-block {\n display: block !important;\n }\n\n .d-xl-grid {\n display: grid !important;\n }\n\n .d-xl-table {\n display: table !important;\n }\n\n .d-xl-table-row {\n display: table-row !important;\n }\n\n .d-xl-table-cell {\n display: table-cell !important;\n }\n\n .d-xl-flex {\n display: flex !important;\n }\n\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n\n .d-xl-none {\n display: none !important;\n }\n\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n\n .flex-xl-row {\n flex-direction: row !important;\n }\n\n .flex-xl-column {\n flex-direction: column !important;\n }\n\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n\n .gap-xl-0 {\n gap: 0 !important;\n }\n\n .gap-xl-1 {\n gap: 0.25rem !important;\n }\n\n .gap-xl-2 {\n gap: 0.5rem !important;\n }\n\n .gap-xl-3 {\n gap: 1rem !important;\n }\n\n .gap-xl-4 {\n gap: 1.5rem !important;\n }\n\n .gap-xl-5 {\n gap: 3rem !important;\n }\n\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n\n .justify-content-xl-center {\n justify-content: center !important;\n }\n\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n\n .align-items-xl-center {\n align-items: center !important;\n }\n\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n\n .align-content-xl-center {\n align-content: center !important;\n }\n\n .align-content-xl-between {\n align-content: space-between !important;\n }\n\n .align-content-xl-around {\n align-content: space-around !important;\n }\n\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n\n .align-self-xl-auto {\n align-self: auto !important;\n }\n\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n\n .align-self-xl-center {\n align-self: center !important;\n }\n\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n\n .order-xl-first {\n order: -1 !important;\n }\n\n .order-xl-0 {\n order: 0 !important;\n }\n\n .order-xl-1 {\n order: 1 !important;\n }\n\n .order-xl-2 {\n order: 2 !important;\n }\n\n .order-xl-3 {\n order: 3 !important;\n }\n\n .order-xl-4 {\n order: 4 !important;\n }\n\n .order-xl-5 {\n order: 5 !important;\n }\n\n .order-xl-last {\n order: 6 !important;\n }\n\n .m-xl-0 {\n margin: 0 !important;\n }\n\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n\n .m-xl-3 {\n margin: 1rem !important;\n }\n\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n\n .m-xl-5 {\n margin: 3rem !important;\n }\n\n .m-xl-auto {\n margin: auto !important;\n }\n\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n\n .mt-xl-auto {\n margin-top: auto !important;\n }\n\n .me-xl-0 {\n margin-right: 0 !important;\n }\n\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n\n .me-xl-auto {\n margin-right: auto !important;\n }\n\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n\n .ms-xl-auto {\n margin-left: auto !important;\n }\n\n .p-xl-0 {\n padding: 0 !important;\n }\n\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n\n .p-xl-3 {\n padding: 1rem !important;\n }\n\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n\n .p-xl-5 {\n padding: 3rem !important;\n }\n\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n\n .text-xl-start {\n text-align: left !important;\n }\n\n .text-xl-end {\n text-align: right !important;\n }\n\n .text-xl-center {\n text-align: center !important;\n }\n}\n@media (min-width: 1400px) {\n .float-xxl-start {\n float: left !important;\n }\n\n .float-xxl-end {\n float: right !important;\n }\n\n .float-xxl-none {\n float: none !important;\n }\n\n .d-xxl-inline {\n display: inline !important;\n }\n\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n\n .d-xxl-block {\n display: block !important;\n }\n\n .d-xxl-grid {\n display: grid !important;\n }\n\n .d-xxl-table {\n display: table !important;\n }\n\n .d-xxl-table-row {\n display: table-row !important;\n }\n\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n\n .d-xxl-flex {\n display: flex !important;\n }\n\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n\n .d-xxl-none {\n display: none !important;\n }\n\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n\n .flex-xxl-row {\n flex-direction: row !important;\n }\n\n .flex-xxl-column {\n flex-direction: column !important;\n }\n\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n\n .gap-xxl-0 {\n gap: 0 !important;\n }\n\n .gap-xxl-1 {\n gap: 0.25rem !important;\n }\n\n .gap-xxl-2 {\n gap: 0.5rem !important;\n }\n\n .gap-xxl-3 {\n gap: 1rem !important;\n }\n\n .gap-xxl-4 {\n gap: 1.5rem !important;\n }\n\n .gap-xxl-5 {\n gap: 3rem !important;\n }\n\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n\n .align-items-xxl-center {\n align-items: center !important;\n }\n\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n\n .align-content-xxl-center {\n align-content: center !important;\n }\n\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n\n .align-self-xxl-center {\n align-self: center !important;\n }\n\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n\n .order-xxl-first {\n order: -1 !important;\n }\n\n .order-xxl-0 {\n order: 0 !important;\n }\n\n .order-xxl-1 {\n order: 1 !important;\n }\n\n .order-xxl-2 {\n order: 2 !important;\n }\n\n .order-xxl-3 {\n order: 3 !important;\n }\n\n .order-xxl-4 {\n order: 4 !important;\n }\n\n .order-xxl-5 {\n order: 5 !important;\n }\n\n .order-xxl-last {\n order: 6 !important;\n }\n\n .m-xxl-0 {\n margin: 0 !important;\n }\n\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n\n .m-xxl-3 {\n margin: 1rem !important;\n }\n\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n\n .m-xxl-5 {\n margin: 3rem !important;\n }\n\n .m-xxl-auto {\n margin: auto !important;\n }\n\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n\n .me-xxl-auto {\n margin-right: auto !important;\n }\n\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n\n .p-xxl-0 {\n padding: 0 !important;\n }\n\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n\n .p-xxl-3 {\n padding: 1rem !important;\n }\n\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n\n .p-xxl-5 {\n padding: 3rem !important;\n }\n\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n\n .text-xxl-start {\n text-align: left !important;\n }\n\n .text-xxl-end {\n text-align: right !important;\n }\n\n .text-xxl-center {\n text-align: center !important;\n }\n}\n@media (min-width: 1200px) {\n .fs-1 {\n font-size: 2.5rem !important;\n }\n\n .fs-2 {\n font-size: 2rem !important;\n }\n\n .fs-3 {\n font-size: 1.75rem !important;\n }\n\n .fs-4 {\n font-size: 1.5rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n\n .d-print-inline-block {\n display: inline-block !important;\n }\n\n .d-print-block {\n display: block !important;\n }\n\n .d-print-grid {\n display: grid !important;\n }\n\n .d-print-table {\n display: table !important;\n }\n\n .d-print-table-row {\n display: table-row !important;\n }\n\n .d-print-table-cell {\n display: table-cell !important;\n }\n\n .d-print-flex {\n display: flex !important;\n }\n\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap.css.map */","// stylelint-disable property-blacklist, scss/dollar-variable-default\n\n// SCSS RFS mixin\n//\n// Automated responsive values for font sizes, paddings, margins and much more\n//\n// Licensed under MIT (https://github.com/twbs/rfs/blob/main/LICENSE)\n\n// Configuration\n\n// Base value\n$rfs-base-value: 1.25rem !default;\n$rfs-unit: rem !default;\n\n@if $rfs-unit != rem and $rfs-unit != px {\n @error \"`#{$rfs-unit}` is not a valid unit for $rfs-unit. Use `px` or `rem`.\";\n}\n\n// Breakpoint at where values start decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n@if $rfs-breakpoint-unit != px and $rfs-breakpoint-unit != em and $rfs-breakpoint-unit != rem {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n}\n\n// Resize values based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != number or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Mode. Possibilities: \"min-media-query\", \"max-media-query\"\n$rfs-mode: min-media-query !default;\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-rfs to false\n$enable-rfs: true !default;\n\n// Cache $rfs-base-value unit\n$rfs-base-value-unit: unit($rfs-base-value);\n\n@function divide($dividend, $divisor, $precision: 10) {\n $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);\n $dividend: abs($dividend);\n $divisor: abs($divisor);\n @if $dividend == 0 {\n @return 0;\n }\n @if $divisor == 0 {\n @error \"Cannot divide by 0\";\n }\n $remainder: $dividend;\n $result: 0;\n $factor: 10;\n @while ($remainder > 0 and $precision >= 0) {\n $quotient: 0;\n @while ($remainder >= $divisor) {\n $remainder: $remainder - $divisor;\n $quotient: $quotient + 1;\n }\n $result: $result * 10 + $quotient;\n $factor: $factor * .1;\n $remainder: $remainder * 10;\n $precision: $precision - 1;\n @if ($precision < 0 and $remainder >= $divisor * 5) {\n $result: $result + 1;\n }\n }\n $result: $result * $factor * $sign;\n $dividend-unit: unit($dividend);\n $divisor-unit: unit($divisor);\n $unit-map: (\n \"px\": 1px,\n \"rem\": 1rem,\n \"em\": 1em,\n \"%\": 1%\n );\n @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {\n $result: $result * map-get($unit-map, $dividend-unit);\n }\n @return $result;\n}\n\n// Remove px-unit from $rfs-base-value for calculations\n@if $rfs-base-value-unit == px {\n $rfs-base-value: divide($rfs-base-value, $rfs-base-value * 0 + 1);\n}\n@else if $rfs-base-value-unit == rem {\n $rfs-base-value: divide($rfs-base-value, divide($rfs-base-value * 0 + 1, $rfs-rem-value));\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == px {\n $rfs-breakpoint: divide($rfs-breakpoint, $rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == rem or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: divide($rfs-breakpoint, divide($rfs-breakpoint * 0 + 1, $rfs-rem-value));\n}\n\n// Calculate the media query value\n$rfs-mq-value: if($rfs-breakpoint-unit == px, #{$rfs-breakpoint}px, #{divide($rfs-breakpoint, $rfs-rem-value)}#{$rfs-breakpoint-unit});\n$rfs-mq-property-width: if($rfs-mode == max-media-query, max-width, min-width);\n$rfs-mq-property-height: if($rfs-mode == max-media-query, max-height, min-height);\n\n// Internal mixin used to determine which media query needs to be used\n@mixin _rfs-media-query {\n @if $rfs-two-dimensional {\n @if $rfs-mode == max-media-query {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}), (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) and (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) {\n @content;\n }\n }\n}\n\n// Internal mixin that adds disable classes to the selector if needed.\n@mixin _rfs-rule {\n @if $rfs-class == disable and $rfs-mode == max-media-query {\n // Adding an extra class increases specificity, which prevents the media query to override the property\n &,\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @else if $rfs-class == enable and $rfs-mode == min-media-query {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n }\n @else {\n @content;\n }\n}\n\n// Internal mixin that adds enable classes to the selector if needed.\n@mixin _rfs-media-query-rule {\n\n @if $rfs-class == enable {\n @if $rfs-mode == min-media-query {\n @content;\n }\n\n @include _rfs-media-query {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n }\n }\n @else {\n @if $rfs-class == disable and $rfs-mode == min-media-query {\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @include _rfs-media-query {\n @content;\n }\n }\n}\n\n// Helper function to get the formatted non-responsive value\n@function rfs-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: '';\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + ' 0';\n }\n @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n @if $unit == px {\n // Convert to rem if needed\n $val: $val + ' ' + if($rfs-unit == rem, #{divide($value, $value * 0 + $rfs-rem-value)}rem, $value);\n }\n @else if $unit == rem {\n // Convert to px if needed\n $val: $val + ' ' + if($rfs-unit == px, #{divide($value, $value * 0 + 1) * $rfs-rem-value}px, $value);\n }\n @else {\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n $val: $val + ' ' + $value;\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// Helper function to get the responsive value calculated by RFS\n@function rfs-fluid-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: '';\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + ' 0';\n }\n\n @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $unit or $unit != px and $unit != rem {\n $val: $val + ' ' + $value;\n }\n\n @else {\n // Remove unit from $value for calculations\n $value: divide($value, $value * 0 + if($unit == px, 1, divide(1, $rfs-rem-value)));\n\n // Only add the media query if the value is greater than the minimum value\n @if abs($value) <= $rfs-base-value or not $enable-rfs {\n $val: $val + ' ' + if($rfs-unit == rem, #{divide($value, $rfs-rem-value)}rem, #{$value}px);\n }\n @else {\n // Calculate the minimum value\n $value-min: $rfs-base-value + divide(abs($value) - $rfs-base-value, $rfs-factor);\n\n // Calculate difference between $value and the minimum value\n $value-diff: abs($value) - $value-min;\n\n // Base value formatting\n $min-width: if($rfs-unit == rem, #{divide($value-min, $rfs-rem-value)}rem, #{$value-min}px);\n\n // Use negative value if needed\n $min-width: if($value < 0, -$min-width, $min-width);\n\n // Use `vmin` if two-dimensional is enabled\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{divide($value-diff * 100, $rfs-breakpoint)}#{$variable-unit};\n\n // Return the calculated value\n $val: $val + ' calc(' + $min-width + if($value < 0, ' - ', ' + ') + $variable-width + ')';\n }\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// RFS mixin\n@mixin rfs($values, $property: font-size) {\n @if $values != null {\n $val: rfs-value($values);\n $fluidVal: rfs-fluid-value($values);\n\n // Do not print the media query if responsive & non-responsive values are the same\n @if $val == $fluidVal {\n #{$property}: $val;\n }\n @else {\n @include _rfs-rule {\n #{$property}: if($rfs-mode == max-media-query, $val, $fluidVal);\n\n // Include safari iframe resize fix if needed\n min-width: if($rfs-safari-iframe-resize-bug-fix, (0 * 1vw), null);\n }\n\n @include _rfs-media-query-rule {\n #{$property}: if($rfs-mode == max-media-query, $fluidVal, $val);\n }\n }\n }\n}\n\n// Shorthand helper mixins\n@mixin font-size($value) {\n @include rfs($value);\n}\n\n@mixin padding($value) {\n @include rfs($value, padding);\n}\n\n@mixin padding-top($value) {\n @include rfs($value, padding-top);\n}\n\n@mixin padding-right($value) {\n @include rfs($value, padding-right);\n}\n\n@mixin padding-bottom($value) {\n @include rfs($value, padding-bottom);\n}\n\n@mixin padding-left($value) {\n @include rfs($value, padding-left);\n}\n\n@mixin margin($value) {\n @include rfs($value, margin);\n}\n\n@mixin margin-top($value) {\n @include rfs($value, margin-top);\n}\n\n@mixin margin-right($value) {\n @include rfs($value, margin-right);\n}\n\n@mixin margin-bottom($value) {\n @include rfs($value, margin-bottom);\n}\n\n@mixin margin-left($value) {\n @include rfs($value, margin-left);\n}\n","// stylelint-disable property-disallowed-list\n// Single side border-radius\n\n// Helper function to replace negative values with 0\n@function valid-radius($radius) {\n $return: ();\n @each $value in $radius {\n @if type-of($value) == number {\n $return: append($return, max($value, 0));\n } @else {\n $return: append($return, $value);\n }\n }\n @return $return;\n}\n\n// scss-docs-start border-radius-mixins\n@mixin border-radius($radius: $border-radius, $fallback-border-radius: false) {\n @if $enable-rounded {\n border-radius: valid-radius($radius);\n }\n @else if $fallback-border-radius != false {\n border-radius: $fallback-border-radius;\n }\n}\n\n@mixin border-top-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n// scss-docs-end border-radius-mixins\n","//\n// Headings\n//\n.h1 {\n @extend h1;\n}\n\n.h2 {\n @extend h2;\n}\n\n.h3 {\n @extend h3;\n}\n\n.h4 {\n @extend h4;\n}\n\n.h5 {\n @extend h5;\n}\n\n.h6 {\n @extend h6;\n}\n\n\n.lead {\n @include font-size($lead-font-size);\n font-weight: $lead-font-weight;\n}\n\n// Type display classes\n@each $display, $font-size in $display-font-sizes {\n .display-#{$display} {\n @include font-size($font-size);\n font-weight: $display-font-weight;\n line-height: $display-line-height;\n }\n}\n\n//\n// Emphasis\n//\n.small {\n @extend small;\n}\n\n.mark {\n @extend mark;\n}\n\n//\n// Lists\n//\n\n.list-unstyled {\n @include list-unstyled();\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n @include list-unstyled();\n}\n.list-inline-item {\n display: inline-block;\n\n &:not(:last-child) {\n margin-right: $list-inline-padding;\n }\n}\n\n\n//\n// Misc\n//\n\n// Builds on `abbr`\n.initialism {\n @include font-size($initialism-font-size);\n text-transform: uppercase;\n}\n\n// Blockquotes\n.blockquote {\n margin-bottom: $blockquote-margin-y;\n @include font-size($blockquote-font-size);\n\n > :last-child {\n margin-bottom: 0;\n }\n}\n\n.blockquote-footer {\n margin-top: -$blockquote-margin-y;\n margin-bottom: $blockquote-margin-y;\n @include font-size($blockquote-footer-font-size);\n color: $blockquote-footer-color;\n\n &::before {\n content: \"\\2014\\00A0\"; // em dash, nbsp\n }\n}\n","// Lists\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n@mixin list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n","// Responsive images (ensure images don't scale beyond their parents)\n//\n// This is purposefully opt-in via an explicit class rather than being the default for all `<img>`s.\n// We previously tried the \"images are responsive by default\" approach in Bootstrap v2,\n// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)\n// which weren't expecting the images within themselves to be involuntarily resized.\n// See also https://github.com/twbs/bootstrap/issues/18178\n.img-fluid {\n @include img-fluid();\n}\n\n\n// Image thumbnails\n.img-thumbnail {\n padding: $thumbnail-padding;\n background-color: $thumbnail-bg;\n border: $thumbnail-border-width solid $thumbnail-border-color;\n @include border-radius($thumbnail-border-radius);\n @include box-shadow($thumbnail-box-shadow);\n\n // Keep them at most 100% wide\n @include img-fluid();\n}\n\n//\n// Figures\n//\n\n.figure {\n // Ensures the caption's text aligns with the image.\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: $spacer * .5;\n line-height: 1;\n}\n\n.figure-caption {\n @include font-size($figure-caption-font-size);\n color: $figure-caption-color;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n\n@mixin img-fluid {\n // Part 1: Set a maximum relative to the parent\n max-width: 100%;\n // Part 2: Override the height to auto, otherwise images will be stretched\n // when setting a width and height attribute on the img element.\n height: auto;\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-grid-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n width: 100%;\n padding-right: var(--#{$variable-prefix}gutter-x, #{$gutter});\n padding-left: var(--#{$variable-prefix}gutter-x, #{$gutter});\n margin-right: auto;\n margin-left: auto;\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Row\n//\n// Rows contain your columns.\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$variable-prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$variable-prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$variable-prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$variable-prefix}gutter-x: #{$gutter};\n --#{$variable-prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(var(--#{$variable-prefix}gutter-y) * -1); // stylelint-disable-line function-disallowed-list\n margin-right: calc(var(--#{$variable-prefix}gutter-x) * -.5); // stylelint-disable-line function-disallowed-list\n margin-left: calc(var(--#{$variable-prefix}gutter-x) * -.5); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready($gutter: $grid-gutter-width) {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$variable-prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$variable-prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$variable-prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// numberof columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: divide(100%, $count);\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$variable-prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$variable-prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is and invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","//\n// Basic Bootstrap table\n//\n\n.table {\n --#{$variable-prefix}table-bg: #{$table-bg};\n --#{$variable-prefix}table-accent-bg: #{$table-accent-bg};\n --#{$variable-prefix}table-striped-color: #{$table-striped-color};\n --#{$variable-prefix}table-striped-bg: #{$table-striped-bg};\n --#{$variable-prefix}table-active-color: #{$table-active-color};\n --#{$variable-prefix}table-active-bg: #{$table-active-bg};\n --#{$variable-prefix}table-hover-color: #{$table-hover-color};\n --#{$variable-prefix}table-hover-bg: #{$table-hover-bg};\n\n width: 100%;\n margin-bottom: $spacer;\n color: $table-color;\n vertical-align: $table-cell-vertical-align;\n border-color: $table-border-color;\n\n // Target th & td\n // We need the child combinator to prevent styles leaking to nested tables which doesn't have a `.table` class.\n // We use the universal selectors here to simplify the selector (else we would need 6 different selectors).\n // Another advantage is that this generates less code and makes the selector less specific making it easier to override.\n // stylelint-disable-next-line selector-max-universal\n > :not(caption) > * > * {\n padding: $table-cell-padding-y $table-cell-padding-x;\n background-color: var(--#{$variable-prefix}table-bg);\n border-bottom-width: $table-border-width;\n box-shadow: inset 0 0 0 9999px var(--#{$variable-prefix}table-accent-bg);\n }\n\n > tbody {\n vertical-align: inherit;\n }\n\n > thead {\n vertical-align: bottom;\n }\n\n // Highlight border color between thead, tbody and tfoot.\n > :not(:last-child) > :last-child > * {\n border-bottom-color: $table-group-separator-color;\n }\n}\n\n\n//\n// Change placement of captions with a class\n//\n\n.caption-top {\n caption-side: top;\n}\n\n\n//\n// Condensed table w/ half padding\n//\n\n.table-sm {\n // stylelint-disable-next-line selector-max-universal\n > :not(caption) > * > * {\n padding: $table-cell-padding-y-sm $table-cell-padding-x-sm;\n }\n}\n\n\n// Border versions\n//\n// Add or remove borders all around the table and between all the columns.\n//\n// When borders are added on all sides of the cells, the corners can render odd when\n// these borders do not have the same color or if they are semi-transparent.\n// Therefor we add top and border bottoms to the `tr`s and left and right borders\n// to the `td`s or `th`s\n\n.table-bordered {\n > :not(caption) > * {\n border-width: $table-border-width 0;\n\n // stylelint-disable-next-line selector-max-universal\n > * {\n border-width: 0 $table-border-width;\n }\n }\n}\n\n.table-borderless {\n // stylelint-disable-next-line selector-max-universal\n > :not(caption) > * > * {\n border-bottom-width: 0;\n }\n}\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n > tbody > tr:nth-of-type(#{$table-striped-order}) {\n --#{$variable-prefix}table-accent-bg: var(--#{$variable-prefix}table-striped-bg);\n color: var(--#{$variable-prefix}table-striped-color);\n }\n}\n\n// Active table\n//\n// The `.table-active` class can be added to highlight rows or cells\n\n.table-active {\n --#{$variable-prefix}table-accent-bg: var(--#{$variable-prefix}table-active-bg);\n color: var(--#{$variable-prefix}table-active-color);\n}\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n > tbody > tr:hover {\n --#{$variable-prefix}table-accent-bg: var(--#{$variable-prefix}table-hover-bg);\n color: var(--#{$variable-prefix}table-hover-color);\n }\n}\n\n\n// Table variants\n//\n// Table variants set the table cell backgrounds, border colors\n// and the colors of the striped, hovered & active tables\n\n@each $color, $value in $table-variants {\n @include table-variant($color, $value);\n}\n\n// Responsive tables\n//\n// Generate series of `.table-responsive-*` classes for configuring the screen\n// size of where your table will overflow.\n\n@each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @include media-breakpoint-down($breakpoint) {\n .table-responsive#{$infix} {\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n }\n }\n}\n","// scss-docs-start table-variant\n@mixin table-variant($state, $background) {\n .table-#{$state} {\n $color: color-contrast(opaque($body-bg, $background));\n $hover-bg: mix($color, $background, percentage($table-hover-bg-factor));\n $striped-bg: mix($color, $background, percentage($table-striped-bg-factor));\n $active-bg: mix($color, $background, percentage($table-active-bg-factor));\n\n --#{$variable-prefix}table-bg: #{$background};\n --#{$variable-prefix}table-striped-bg: #{$striped-bg};\n --#{$variable-prefix}table-striped-color: #{color-contrast($striped-bg)};\n --#{$variable-prefix}table-active-bg: #{$active-bg};\n --#{$variable-prefix}table-active-color: #{color-contrast($active-bg)};\n --#{$variable-prefix}table-hover-bg: #{$hover-bg};\n --#{$variable-prefix}table-hover-color: #{color-contrast($hover-bg)};\n\n color: $color;\n border-color: mix($color, $background, percentage($table-border-factor));\n }\n}\n// scss-docs-end table-variant\n","//\n// Labels\n//\n\n.form-label {\n margin-bottom: $form-label-margin-bottom;\n @include font-size($form-label-font-size);\n font-style: $form-label-font-style;\n font-weight: $form-label-font-weight;\n color: $form-label-color;\n}\n\n// For use with horizontal and inline forms, when you need the label (or legend)\n// text to align with the form controls.\n.col-form-label {\n padding-top: add($input-padding-y, $input-border-width);\n padding-bottom: add($input-padding-y, $input-border-width);\n margin-bottom: 0; // Override the `<legend>` default\n @include font-size(inherit); // Override the `<legend>` default\n font-style: $form-label-font-style;\n font-weight: $form-label-font-weight;\n line-height: $input-line-height;\n color: $form-label-color;\n}\n\n.col-form-label-lg {\n padding-top: add($input-padding-y-lg, $input-border-width);\n padding-bottom: add($input-padding-y-lg, $input-border-width);\n @include font-size($input-font-size-lg);\n}\n\n.col-form-label-sm {\n padding-top: add($input-padding-y-sm, $input-border-width);\n padding-bottom: add($input-padding-y-sm, $input-border-width);\n @include font-size($input-font-size-sm);\n}\n","//\n// Form text\n//\n\n.form-text {\n margin-top: $form-text-margin-top;\n @include font-size($form-text-font-size);\n font-style: $form-text-font-style;\n font-weight: $form-text-font-weight;\n color: $form-text-color;\n}\n","//\n// General form controls (plus a few specific high-level interventions)\n//\n\n.form-control {\n display: block;\n width: 100%;\n padding: $input-padding-y $input-padding-x;\n font-family: $input-font-family;\n @include font-size($input-font-size);\n font-weight: $input-font-weight;\n line-height: $input-line-height;\n color: $input-color;\n background-color: $input-bg;\n background-clip: padding-box;\n border: $input-border-width solid $input-border-color;\n appearance: none; // Fix appearance for date inputs in Safari\n\n // Note: This has no effect on <select>s in some browsers, due to the limited stylability of `<select>`s in CSS.\n @include border-radius($input-border-radius, 0);\n\n @include box-shadow($input-box-shadow);\n @include transition($input-transition);\n\n &[type=\"file\"] {\n overflow: hidden; // prevent pseudo element button overlap\n\n &:not(:disabled):not([readonly]) {\n cursor: pointer;\n }\n }\n\n // Customize the `:focus` state to imitate native WebKit styles.\n &:focus {\n color: $input-focus-color;\n background-color: $input-focus-bg;\n border-color: $input-focus-border-color;\n outline: 0;\n @if $enable-shadows {\n @include box-shadow($input-box-shadow, $input-focus-box-shadow);\n } @else {\n // Avoid using mixin so we can pass custom focus shadow properly\n box-shadow: $input-focus-box-shadow;\n }\n }\n\n // Add some height to date inputs on iOS\n // https://github.com/twbs/bootstrap/issues/23307\n // TODO: we can remove this workaround once https://bugs.webkit.org/show_bug.cgi?id=198959 is resolved\n &::-webkit-date-and-time-value {\n // Multiply line-height by 1em if it has no unit\n height: if(unit($input-line-height) == \"\", $input-line-height * 1em, $input-line-height);\n }\n\n // Placeholder\n &::placeholder {\n color: $input-placeholder-color;\n // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526.\n opacity: 1;\n }\n\n // Disabled and read-only inputs\n //\n // HTML5 says that controls under a fieldset > legend:first-child won't be\n // disabled if the fieldset is disabled. Due to implementation difficulty, we\n // don't honor that edge case; we style them as disabled anyway.\n &:disabled,\n &[readonly] {\n background-color: $input-disabled-bg;\n border-color: $input-disabled-border-color;\n // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655.\n opacity: 1;\n }\n\n // File input buttons theming\n &::file-selector-button {\n padding: $input-padding-y $input-padding-x;\n margin: (-$input-padding-y) (-$input-padding-x);\n margin-inline-end: $input-padding-x;\n color: $form-file-button-color;\n @include gradient-bg($form-file-button-bg);\n pointer-events: none;\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n border-inline-end-width: $input-border-width;\n border-radius: 0; // stylelint-disable-line property-disallowed-list\n @include transition($btn-transition);\n }\n\n &:hover:not(:disabled):not([readonly])::file-selector-button {\n background-color: $form-file-button-hover-bg;\n }\n\n &::-webkit-file-upload-button {\n padding: $input-padding-y $input-padding-x;\n margin: (-$input-padding-y) (-$input-padding-x);\n margin-inline-end: $input-padding-x;\n color: $form-file-button-color;\n @include gradient-bg($form-file-button-bg);\n pointer-events: none;\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n border-inline-end-width: $input-border-width;\n border-radius: 0; // stylelint-disable-line property-disallowed-list\n @include transition($btn-transition);\n }\n\n &:hover:not(:disabled):not([readonly])::-webkit-file-upload-button {\n background-color: $form-file-button-hover-bg;\n }\n}\n\n// Readonly controls as plain text\n//\n// Apply class to a readonly input to make it appear like regular plain\n// text (without any border, background color, focus indicator)\n\n.form-control-plaintext {\n display: block;\n width: 100%;\n padding: $input-padding-y 0;\n margin-bottom: 0; // match inputs if this class comes on inputs with default margins\n line-height: $input-line-height;\n color: $input-plaintext-color;\n background-color: transparent;\n border: solid transparent;\n border-width: $input-border-width 0;\n\n &.form-control-sm,\n &.form-control-lg {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n// Form control sizing\n//\n// Build on `.form-control` with modifier classes to decrease or increase the\n// height and font-size of form controls.\n//\n// Repeated in `_input_group.scss` to avoid Sass extend issues.\n\n.form-control-sm {\n min-height: $input-height-sm;\n padding: $input-padding-y-sm $input-padding-x-sm;\n @include font-size($input-font-size-sm);\n @include border-radius($input-border-radius-sm);\n\n &::file-selector-button {\n padding: $input-padding-y-sm $input-padding-x-sm;\n margin: (-$input-padding-y-sm) (-$input-padding-x-sm);\n margin-inline-end: $input-padding-x-sm;\n }\n\n &::-webkit-file-upload-button {\n padding: $input-padding-y-sm $input-padding-x-sm;\n margin: (-$input-padding-y-sm) (-$input-padding-x-sm);\n margin-inline-end: $input-padding-x-sm;\n }\n}\n\n.form-control-lg {\n min-height: $input-height-lg;\n padding: $input-padding-y-lg $input-padding-x-lg;\n @include font-size($input-font-size-lg);\n @include border-radius($input-border-radius-lg);\n\n &::file-selector-button {\n padding: $input-padding-y-lg $input-padding-x-lg;\n margin: (-$input-padding-y-lg) (-$input-padding-x-lg);\n margin-inline-end: $input-padding-x-lg;\n }\n\n &::-webkit-file-upload-button {\n padding: $input-padding-y-lg $input-padding-x-lg;\n margin: (-$input-padding-y-lg) (-$input-padding-x-lg);\n margin-inline-end: $input-padding-x-lg;\n }\n}\n\n// Make sure textareas don't shrink too much when resized\n// https://github.com/twbs/bootstrap/pull/29124\n// stylelint-disable selector-no-qualifying-type\ntextarea {\n &.form-control {\n min-height: $input-height;\n }\n\n &.form-control-sm {\n min-height: $input-height-sm;\n }\n\n &.form-control-lg {\n min-height: $input-height-lg;\n }\n}\n// stylelint-enable selector-no-qualifying-type\n\n.form-control-color {\n width: $form-color-width;\n height: auto; // Override fixed browser height\n padding: $input-padding-y;\n\n &:not(:disabled):not([readonly]) {\n cursor: pointer;\n }\n\n &::-moz-color-swatch {\n height: if(unit($input-line-height) == \"\", $input-line-height * 1em, $input-line-height);\n @include border-radius($input-border-radius);\n }\n\n &::-webkit-color-swatch {\n height: if(unit($input-line-height) == \"\", $input-line-height * 1em, $input-line-height);\n @include border-radius($input-border-radius);\n }\n}\n","// stylelint-disable property-disallowed-list\n@mixin transition($transition...) {\n @if length($transition) == 0 {\n $transition: $transition-base;\n }\n\n @if length($transition) > 1 {\n @each $value in $transition {\n @if $value == null or $value == none {\n @warn \"The keyword 'none' or 'null' must be used as a single argument.\";\n }\n }\n }\n\n @if $enable-transitions {\n @if nth($transition, 1) != null {\n transition: $transition;\n }\n\n @if $enable-reduced-motion and nth($transition, 1) != null and nth($transition, 1) != none {\n @media (prefers-reduced-motion: reduce) {\n transition: none;\n }\n }\n }\n}\n","// Gradients\n\n// scss-docs-start gradient-bg-mixin\n@mixin gradient-bg($color: null) {\n background-color: $color;\n\n @if $enable-gradients {\n background-image: var(--#{$variable-prefix}gradient);\n }\n}\n// scss-docs-end gradient-bg-mixin\n\n// scss-docs-start gradient-mixins\n// Horizontal gradient, from left to right\n//\n// Creates two color stops, start and end, by specifying a color and position for each color stop.\n@mixin gradient-x($start-color: $gray-700, $end-color: $gray-800, $start-percent: 0%, $end-percent: 100%) {\n background-image: linear-gradient(to right, $start-color $start-percent, $end-color $end-percent);\n}\n\n// Vertical gradient, from top to bottom\n//\n// Creates two color stops, start and end, by specifying a color and position for each color stop.\n@mixin gradient-y($start-color: $gray-700, $end-color: $gray-800, $start-percent: null, $end-percent: null) {\n background-image: linear-gradient(to bottom, $start-color $start-percent, $end-color $end-percent);\n}\n\n@mixin gradient-directional($start-color: $gray-700, $end-color: $gray-800, $deg: 45deg) {\n background-image: linear-gradient($deg, $start-color, $end-color);\n}\n\n@mixin gradient-x-three-colors($start-color: $blue, $mid-color: $purple, $color-stop: 50%, $end-color: $red) {\n background-image: linear-gradient(to right, $start-color, $mid-color $color-stop, $end-color);\n}\n\n@mixin gradient-y-three-colors($start-color: $blue, $mid-color: $purple, $color-stop: 50%, $end-color: $red) {\n background-image: linear-gradient($start-color, $mid-color $color-stop, $end-color);\n}\n\n@mixin gradient-radial($inner-color: $gray-700, $outer-color: $gray-800) {\n background-image: radial-gradient(circle, $inner-color, $outer-color);\n}\n\n@mixin gradient-striped($color: rgba($white, .15), $angle: 45deg) {\n background-image: linear-gradient($angle, $color 25%, transparent 25%, transparent 50%, $color 50%, $color 75%, transparent 75%, transparent);\n}\n// scss-docs-end gradient-mixins\n","// Select\n//\n// Replaces the browser default select with a custom one, mostly pulled from\n// https://primer.github.io/.\n\n.form-select {\n display: block;\n width: 100%;\n padding: $form-select-padding-y $form-select-indicator-padding $form-select-padding-y $form-select-padding-x;\n // stylelint-disable-next-line property-no-vendor-prefix\n -moz-padding-start: subtract($form-select-padding-x, 3px); // See https://github.com/twbs/bootstrap/issues/32636\n font-family: $form-select-font-family;\n @include font-size($form-select-font-size);\n font-weight: $form-select-font-weight;\n line-height: $form-select-line-height;\n color: $form-select-color;\n background-color: $form-select-bg;\n background-image: escape-svg($form-select-indicator);\n background-repeat: no-repeat;\n background-position: $form-select-bg-position;\n background-size: $form-select-bg-size;\n border: $form-select-border-width solid $form-select-border-color;\n @include border-radius($form-select-border-radius, 0);\n @include box-shadow($form-select-box-shadow);\n @include transition($form-select-transition);\n appearance: none;\n\n &:focus {\n border-color: $form-select-focus-border-color;\n outline: 0;\n @if $enable-shadows {\n @include box-shadow($form-select-box-shadow, $form-select-focus-box-shadow);\n } @else {\n // Avoid using mixin so we can pass custom focus shadow properly\n box-shadow: $form-select-focus-box-shadow;\n }\n }\n\n &[multiple],\n &[size]:not([size=\"1\"]) {\n padding-right: $form-select-padding-x;\n background-image: none;\n }\n\n &:disabled {\n color: $form-select-disabled-color;\n background-color: $form-select-disabled-bg;\n border-color: $form-select-disabled-border-color;\n }\n\n // Remove outline from select box in FF\n &:-moz-focusring {\n color: transparent;\n text-shadow: 0 0 0 $form-select-color;\n }\n}\n\n.form-select-sm {\n padding-top: $form-select-padding-y-sm;\n padding-bottom: $form-select-padding-y-sm;\n padding-left: $form-select-padding-x-sm;\n @include font-size($form-select-font-size-sm);\n}\n\n.form-select-lg {\n padding-top: $form-select-padding-y-lg;\n padding-bottom: $form-select-padding-y-lg;\n padding-left: $form-select-padding-x-lg;\n @include font-size($form-select-font-size-lg);\n}\n","//\n// Check/radio\n//\n\n.form-check {\n display: block;\n min-height: $form-check-min-height;\n padding-left: $form-check-padding-start;\n margin-bottom: $form-check-margin-bottom;\n\n .form-check-input {\n float: left;\n margin-left: $form-check-padding-start * -1;\n }\n}\n\n.form-check-input {\n width: $form-check-input-width;\n height: $form-check-input-width;\n margin-top: ($line-height-base - $form-check-input-width) * .5; // line-height minus check height\n vertical-align: top;\n background-color: $form-check-input-bg;\n background-repeat: no-repeat;\n background-position: center;\n background-size: contain;\n border: $form-check-input-border;\n appearance: none;\n color-adjust: exact; // Keep themed appearance for print\n @include transition($form-check-transition);\n\n &[type=\"checkbox\"] {\n @include border-radius($form-check-input-border-radius);\n }\n\n &[type=\"radio\"] {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: $form-check-radio-border-radius;\n }\n\n &:active {\n filter: $form-check-input-active-filter;\n }\n\n &:focus {\n border-color: $form-check-input-focus-border;\n outline: 0;\n box-shadow: $form-check-input-focus-box-shadow;\n }\n\n &:checked {\n background-color: $form-check-input-checked-bg-color;\n border-color: $form-check-input-checked-border-color;\n\n &[type=\"checkbox\"] {\n @if $enable-gradients {\n background-image: escape-svg($form-check-input-checked-bg-image), var(--#{$variable-prefix}gradient);\n } @else {\n background-image: escape-svg($form-check-input-checked-bg-image);\n }\n }\n\n &[type=\"radio\"] {\n @if $enable-gradients {\n background-image: escape-svg($form-check-radio-checked-bg-image), var(--#{$variable-prefix}gradient);\n } @else {\n background-image: escape-svg($form-check-radio-checked-bg-image);\n }\n }\n }\n\n &[type=\"checkbox\"]:indeterminate {\n background-color: $form-check-input-indeterminate-bg-color;\n border-color: $form-check-input-indeterminate-border-color;\n\n @if $enable-gradients {\n background-image: escape-svg($form-check-input-indeterminate-bg-image), var(--#{$variable-prefix}gradient);\n } @else {\n background-image: escape-svg($form-check-input-indeterminate-bg-image);\n }\n }\n\n &:disabled {\n pointer-events: none;\n filter: none;\n opacity: $form-check-input-disabled-opacity;\n }\n\n // Use disabled attribute in addition of :disabled pseudo-class\n // See: https://github.com/twbs/bootstrap/issues/28247\n &[disabled],\n &:disabled {\n ~ .form-check-label {\n opacity: $form-check-label-disabled-opacity;\n }\n }\n}\n\n.form-check-label {\n color: $form-check-label-color;\n cursor: $form-check-label-cursor;\n}\n\n//\n// Switch\n//\n\n.form-switch {\n padding-left: $form-switch-padding-start;\n\n .form-check-input {\n width: $form-switch-width;\n margin-left: $form-switch-padding-start * -1;\n background-image: escape-svg($form-switch-bg-image);\n background-position: left center;\n @include border-radius($form-switch-border-radius);\n @include transition($form-switch-transition);\n\n &:focus {\n background-image: escape-svg($form-switch-focus-bg-image);\n }\n\n &:checked {\n background-position: $form-switch-checked-bg-position;\n\n @if $enable-gradients {\n background-image: escape-svg($form-switch-checked-bg-image), var(--#{$variable-prefix}gradient);\n } @else {\n background-image: escape-svg($form-switch-checked-bg-image);\n }\n }\n }\n}\n\n.form-check-inline {\n display: inline-block;\n margin-right: $form-check-inline-margin-end;\n}\n\n.btn-check {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n\n &[disabled],\n &:disabled {\n + .btn {\n pointer-events: none;\n filter: none;\n opacity: $form-check-btn-check-disabled-opacity;\n }\n }\n}\n","// Range\n//\n// Style range inputs the same across browsers. Vendor-specific rules for pseudo\n// elements cannot be mixed. As such, there are no shared styles for focus or\n// active states on prefixed selectors.\n\n.form-range {\n width: 100%;\n height: add($form-range-thumb-height, $form-range-thumb-focus-box-shadow-width * 2);\n padding: 0; // Need to reset padding\n background-color: transparent;\n appearance: none;\n\n &:focus {\n outline: 0;\n\n // Pseudo-elements must be split across multiple rulesets to have an effect.\n // No box-shadow() mixin for focus accessibility.\n &::-webkit-slider-thumb { box-shadow: $form-range-thumb-focus-box-shadow; }\n &::-moz-range-thumb { box-shadow: $form-range-thumb-focus-box-shadow; }\n }\n\n &::-moz-focus-outer {\n border: 0;\n }\n\n &::-webkit-slider-thumb {\n width: $form-range-thumb-width;\n height: $form-range-thumb-height;\n margin-top: ($form-range-track-height - $form-range-thumb-height) * .5; // Webkit specific\n @include gradient-bg($form-range-thumb-bg);\n border: $form-range-thumb-border;\n @include border-radius($form-range-thumb-border-radius);\n @include box-shadow($form-range-thumb-box-shadow);\n @include transition($form-range-thumb-transition);\n appearance: none;\n\n &:active {\n @include gradient-bg($form-range-thumb-active-bg);\n }\n }\n\n &::-webkit-slider-runnable-track {\n width: $form-range-track-width;\n height: $form-range-track-height;\n color: transparent; // Why?\n cursor: $form-range-track-cursor;\n background-color: $form-range-track-bg;\n border-color: transparent;\n @include border-radius($form-range-track-border-radius);\n @include box-shadow($form-range-track-box-shadow);\n }\n\n &::-moz-range-thumb {\n width: $form-range-thumb-width;\n height: $form-range-thumb-height;\n @include gradient-bg($form-range-thumb-bg);\n border: $form-range-thumb-border;\n @include border-radius($form-range-thumb-border-radius);\n @include box-shadow($form-range-thumb-box-shadow);\n @include transition($form-range-thumb-transition);\n appearance: none;\n\n &:active {\n @include gradient-bg($form-range-thumb-active-bg);\n }\n }\n\n &::-moz-range-track {\n width: $form-range-track-width;\n height: $form-range-track-height;\n color: transparent;\n cursor: $form-range-track-cursor;\n background-color: $form-range-track-bg;\n border-color: transparent; // Firefox specific?\n @include border-radius($form-range-track-border-radius);\n @include box-shadow($form-range-track-box-shadow);\n }\n\n &:disabled {\n pointer-events: none;\n\n &::-webkit-slider-thumb {\n background-color: $form-range-thumb-disabled-bg;\n }\n\n &::-moz-range-thumb {\n background-color: $form-range-thumb-disabled-bg;\n }\n }\n}\n",".form-floating {\n position: relative;\n\n > .form-control,\n > .form-select {\n height: $form-floating-height;\n line-height: $form-floating-line-height;\n }\n\n > label {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%; // allow textareas\n padding: $form-floating-padding-y $form-floating-padding-x;\n pointer-events: none;\n border: $input-border-width solid transparent; // Required for aligning label's text with the input as it affects inner box model\n transform-origin: 0 0;\n @include transition($form-floating-transition);\n }\n\n // stylelint-disable no-duplicate-selectors\n > .form-control {\n padding: $form-floating-padding-y $form-floating-padding-x;\n\n &::placeholder {\n color: transparent;\n }\n\n &:focus,\n &:not(:placeholder-shown) {\n padding-top: $form-floating-input-padding-t;\n padding-bottom: $form-floating-input-padding-b;\n }\n // Duplicated because `:-webkit-autofill` invalidates other selectors when grouped\n &:-webkit-autofill {\n padding-top: $form-floating-input-padding-t;\n padding-bottom: $form-floating-input-padding-b;\n }\n }\n\n > .form-select {\n padding-top: $form-floating-input-padding-t;\n padding-bottom: $form-floating-input-padding-b;\n }\n\n > .form-control:focus,\n > .form-control:not(:placeholder-shown),\n > .form-select {\n ~ label {\n opacity: $form-floating-label-opacity;\n transform: $form-floating-label-transform;\n }\n }\n // Duplicated because `:-webkit-autofill` invalidates other selectors when grouped\n > .form-control:-webkit-autofill {\n ~ label {\n opacity: $form-floating-label-opacity;\n transform: $form-floating-label-transform;\n }\n }\n // stylelint-enable no-duplicate-selectors\n}\n","//\n// Base styles\n//\n\n.input-group {\n position: relative;\n display: flex;\n flex-wrap: wrap; // For form validation feedback\n align-items: stretch;\n width: 100%;\n\n > .form-control,\n > .form-select {\n position: relative; // For focus state's z-index\n flex: 1 1 auto;\n width: 1%;\n min-width: 0; // https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size\n }\n\n // Bring the \"active\" form control to the top of surrounding elements\n > .form-control:focus,\n > .form-select:focus {\n z-index: 3;\n }\n\n // Ensure buttons are always above inputs for more visually pleasing borders.\n // This isn't needed for `.input-group-text` since it shares the same border-color\n // as our inputs.\n .btn {\n position: relative;\n z-index: 2;\n\n &:focus {\n z-index: 3;\n }\n }\n}\n\n\n// Textual addons\n//\n// Serves as a catch-all element for any text or radio/checkbox input you wish\n// to prepend or append to an input.\n\n.input-group-text {\n display: flex;\n align-items: center;\n padding: $input-group-addon-padding-y $input-group-addon-padding-x;\n @include font-size($input-font-size); // Match inputs\n font-weight: $input-group-addon-font-weight;\n line-height: $input-line-height;\n color: $input-group-addon-color;\n text-align: center;\n white-space: nowrap;\n background-color: $input-group-addon-bg;\n border: $input-border-width solid $input-group-addon-border-color;\n @include border-radius($input-border-radius);\n}\n\n\n// Sizing\n//\n// Remix the default form control sizing classes into new ones for easier\n// manipulation.\n\n.input-group-lg > .form-control,\n.input-group-lg > .form-select,\n.input-group-lg > .input-group-text,\n.input-group-lg > .btn {\n padding: $input-padding-y-lg $input-padding-x-lg;\n @include font-size($input-font-size-lg);\n @include border-radius($input-border-radius-lg);\n}\n\n.input-group-sm > .form-control,\n.input-group-sm > .form-select,\n.input-group-sm > .input-group-text,\n.input-group-sm > .btn {\n padding: $input-padding-y-sm $input-padding-x-sm;\n @include font-size($input-font-size-sm);\n @include border-radius($input-border-radius-sm);\n}\n\n.input-group-lg > .form-select,\n.input-group-sm > .form-select {\n padding-right: $form-select-padding-x + $form-select-indicator-padding;\n}\n\n\n// Rounded corners\n//\n// These rulesets must come after the sizing ones to properly override sm and lg\n// border-radius values when extending. They're more specific than we'd like\n// with the `.input-group >` part, but without it, we cannot override the sizing.\n\n// stylelint-disable-next-line no-duplicate-selectors\n.input-group {\n &:not(.has-validation) {\n > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu),\n > .dropdown-toggle:nth-last-child(n + 3) {\n @include border-end-radius(0);\n }\n }\n\n &.has-validation {\n > :nth-last-child(n + 3):not(.dropdown-toggle):not(.dropdown-menu),\n > .dropdown-toggle:nth-last-child(n + 4) {\n @include border-end-radius(0);\n }\n }\n\n $validation-messages: \"\";\n @each $state in map-keys($form-validation-states) {\n $validation-messages: $validation-messages + \":not(.\" + unquote($state) + \"-tooltip)\" + \":not(.\" + unquote($state) + \"-feedback)\";\n }\n\n > :not(:first-child):not(.dropdown-menu)#{$validation-messages} {\n margin-left: -$input-border-width;\n @include border-start-radius(0);\n }\n}\n","// This mixin uses an `if()` technique to be compatible with Dart Sass\n// See https://github.com/sass/sass/issues/1873#issuecomment-152293725 for more details\n\n// scss-docs-start form-validation-mixins\n@mixin form-validation-state-selector($state) {\n @if ($state == \"valid\" or $state == \"invalid\") {\n .was-validated #{if(&, \"&\", \"\")}:#{$state},\n #{if(&, \"&\", \"\")}.is-#{$state} {\n @content;\n }\n } @else {\n #{if(&, \"&\", \"\")}.is-#{$state} {\n @content;\n }\n }\n}\n\n@mixin form-validation-state(\n $state,\n $color,\n $icon,\n $tooltip-color: color-contrast($color),\n $tooltip-bg-color: rgba($color, $form-feedback-tooltip-opacity),\n $focus-box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba($color, $input-btn-focus-color-opacity)\n) {\n .#{$state}-feedback {\n display: none;\n width: 100%;\n margin-top: $form-feedback-margin-top;\n @include font-size($form-feedback-font-size);\n font-style: $form-feedback-font-style;\n color: $color;\n }\n\n .#{$state}-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%; // Contain to parent when possible\n padding: $form-feedback-tooltip-padding-y $form-feedback-tooltip-padding-x;\n margin-top: .1rem;\n @include font-size($form-feedback-tooltip-font-size);\n line-height: $form-feedback-tooltip-line-height;\n color: $tooltip-color;\n background-color: $tooltip-bg-color;\n @include border-radius($form-feedback-tooltip-border-radius);\n }\n\n @include form-validation-state-selector($state) {\n ~ .#{$state}-feedback,\n ~ .#{$state}-tooltip {\n display: block;\n }\n }\n\n .form-control {\n @include form-validation-state-selector($state) {\n border-color: $color;\n\n @if $enable-validation-icons {\n padding-right: $input-height-inner;\n background-image: escape-svg($icon);\n background-repeat: no-repeat;\n background-position: right $input-height-inner-quarter center;\n background-size: $input-height-inner-half $input-height-inner-half;\n }\n\n &:focus {\n border-color: $color;\n box-shadow: $focus-box-shadow;\n }\n }\n }\n\n // stylelint-disable-next-line selector-no-qualifying-type\n textarea.form-control {\n @include form-validation-state-selector($state) {\n @if $enable-validation-icons {\n padding-right: $input-height-inner;\n background-position: top $input-height-inner-quarter right $input-height-inner-quarter;\n }\n }\n }\n\n .form-select {\n @include form-validation-state-selector($state) {\n border-color: $color;\n\n @if $enable-validation-icons {\n &:not([multiple]):not([size]),\n &:not([multiple])[size=\"1\"] {\n padding-right: $form-select-feedback-icon-padding-end;\n background-image: escape-svg($form-select-indicator), escape-svg($icon);\n background-position: $form-select-bg-position, $form-select-feedback-icon-position;\n background-size: $form-select-bg-size, $form-select-feedback-icon-size;\n }\n }\n\n &:focus {\n border-color: $color;\n box-shadow: $focus-box-shadow;\n }\n }\n }\n\n .form-check-input {\n @include form-validation-state-selector($state) {\n border-color: $color;\n\n &:checked {\n background-color: $color;\n }\n\n &:focus {\n box-shadow: $focus-box-shadow;\n }\n\n ~ .form-check-label {\n color: $color;\n }\n }\n }\n .form-check-inline .form-check-input {\n ~ .#{$state}-feedback {\n margin-left: .5em;\n }\n }\n\n .input-group .form-control,\n .input-group .form-select {\n @include form-validation-state-selector($state) {\n @if $state == \"valid\" {\n z-index: 1;\n } @else if $state == \"invalid\" {\n z-index: 2;\n }\n &:focus {\n z-index: 3;\n }\n }\n }\n}\n// scss-docs-end form-validation-mixins\n","//\n// Base styles\n//\n\n.btn {\n display: inline-block;\n font-family: $btn-font-family;\n font-weight: $btn-font-weight;\n line-height: $btn-line-height;\n color: $body-color;\n text-align: center;\n text-decoration: if($link-decoration == none, null, none);\n white-space: $btn-white-space;\n vertical-align: middle;\n cursor: if($enable-button-pointers, pointer, null);\n user-select: none;\n background-color: transparent;\n border: $btn-border-width solid transparent;\n @include button-size($btn-padding-y, $btn-padding-x, $btn-font-size, $btn-border-radius);\n @include transition($btn-transition);\n\n &:hover {\n color: $body-color;\n text-decoration: if($link-hover-decoration == underline, none, null);\n }\n\n .btn-check:focus + &,\n &:focus {\n outline: 0;\n box-shadow: $btn-focus-box-shadow;\n }\n\n .btn-check:checked + &,\n .btn-check:active + &,\n &:active,\n &.active {\n @include box-shadow($btn-active-box-shadow);\n\n &:focus {\n @include box-shadow($btn-focus-box-shadow, $btn-active-box-shadow);\n }\n }\n\n &:disabled,\n &.disabled,\n fieldset:disabled & {\n pointer-events: none;\n opacity: $btn-disabled-opacity;\n @include box-shadow(none);\n }\n}\n\n\n//\n// Alternate buttons\n//\n\n// scss-docs-start btn-variant-loops\n@each $color, $value in $theme-colors {\n .btn-#{$color} {\n @include button-variant($value, $value);\n }\n}\n\n@each $color, $value in $theme-colors {\n .btn-outline-#{$color} {\n @include button-outline-variant($value);\n }\n}\n// scss-docs-end btn-variant-loops\n\n\n//\n// Link buttons\n//\n\n// Make a button look and behave like a link\n.btn-link {\n font-weight: $font-weight-normal;\n color: $btn-link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $btn-link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n\n &:focus {\n text-decoration: $link-hover-decoration;\n }\n\n &:disabled,\n &.disabled {\n color: $btn-link-disabled-color;\n }\n\n // No need for an active state here\n}\n\n\n//\n// Button Sizes\n//\n\n.btn-lg {\n @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg);\n}\n\n.btn-sm {\n @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $btn-border-radius-sm);\n}\n","// Button variants\n//\n// Easily pump out default styles, as well as :hover, :focus, :active,\n// and disabled options for all buttons\n\n// scss-docs-start btn-variant-mixin\n@mixin button-variant(\n $background,\n $border,\n $color: color-contrast($background),\n $hover-background: if($color == $color-contrast-light, shade-color($background, $btn-hover-bg-shade-amount), tint-color($background, $btn-hover-bg-tint-amount)),\n $hover-border: if($color == $color-contrast-light, shade-color($border, $btn-hover-border-shade-amount), tint-color($border, $btn-hover-border-tint-amount)),\n $hover-color: color-contrast($hover-background),\n $active-background: if($color == $color-contrast-light, shade-color($background, $btn-active-bg-shade-amount), tint-color($background, $btn-active-bg-tint-amount)),\n $active-border: if($color == $color-contrast-light, shade-color($border, $btn-active-border-shade-amount), tint-color($border, $btn-active-border-tint-amount)),\n $active-color: color-contrast($active-background),\n $disabled-background: $background,\n $disabled-border: $border,\n $disabled-color: color-contrast($disabled-background)\n) {\n color: $color;\n @include gradient-bg($background);\n border-color: $border;\n @include box-shadow($btn-box-shadow);\n\n &:hover {\n color: $hover-color;\n @include gradient-bg($hover-background);\n border-color: $hover-border;\n }\n\n .btn-check:focus + &,\n &:focus {\n color: $hover-color;\n @include gradient-bg($hover-background);\n border-color: $hover-border;\n @if $enable-shadows {\n @include box-shadow($btn-box-shadow, 0 0 0 $btn-focus-width rgba(mix($color, $border, 15%), .5));\n } @else {\n // Avoid using mixin so we can pass custom focus shadow properly\n box-shadow: 0 0 0 $btn-focus-width rgba(mix($color, $border, 15%), .5);\n }\n }\n\n .btn-check:checked + &,\n .btn-check:active + &,\n &:active,\n &.active,\n .show > &.dropdown-toggle {\n color: $active-color;\n background-color: $active-background;\n // Remove CSS gradients if they're enabled\n background-image: if($enable-gradients, none, null);\n border-color: $active-border;\n\n &:focus {\n @if $enable-shadows {\n @include box-shadow($btn-active-box-shadow, 0 0 0 $btn-focus-width rgba(mix($color, $border, 15%), .5));\n } @else {\n // Avoid using mixin so we can pass custom focus shadow properly\n box-shadow: 0 0 0 $btn-focus-width rgba(mix($color, $border, 15%), .5);\n }\n }\n }\n\n &:disabled,\n &.disabled {\n color: $disabled-color;\n background-color: $disabled-background;\n // Remove CSS gradients if they're enabled\n background-image: if($enable-gradients, none, null);\n border-color: $disabled-border;\n }\n}\n// scss-docs-end btn-variant-mixin\n\n// scss-docs-start btn-outline-variant-mixin\n@mixin button-outline-variant(\n $color,\n $color-hover: color-contrast($color),\n $active-background: $color,\n $active-border: $color,\n $active-color: color-contrast($active-background)\n) {\n color: $color;\n border-color: $color;\n\n &:hover {\n color: $color-hover;\n background-color: $active-background;\n border-color: $active-border;\n }\n\n .btn-check:focus + &,\n &:focus {\n box-shadow: 0 0 0 $btn-focus-width rgba($color, .5);\n }\n\n .btn-check:checked + &,\n .btn-check:active + &,\n &:active,\n &.active,\n &.dropdown-toggle.show {\n color: $active-color;\n background-color: $active-background;\n border-color: $active-border;\n\n &:focus {\n @if $enable-shadows {\n @include box-shadow($btn-active-box-shadow, 0 0 0 $btn-focus-width rgba($color, .5));\n } @else {\n // Avoid using mixin so we can pass custom focus shadow properly\n box-shadow: 0 0 0 $btn-focus-width rgba($color, .5);\n }\n }\n }\n\n &:disabled,\n &.disabled {\n color: $color;\n background-color: transparent;\n }\n}\n// scss-docs-end btn-outline-variant-mixin\n\n// scss-docs-start btn-size-mixin\n@mixin button-size($padding-y, $padding-x, $font-size, $border-radius) {\n padding: $padding-y $padding-x;\n @include font-size($font-size);\n // Manually declare to provide an override to the browser default\n @include border-radius($border-radius, 0);\n}\n// scss-docs-end btn-size-mixin\n",".fade {\n @include transition($transition-fade);\n\n &:not(.show) {\n opacity: 0;\n }\n}\n\n// scss-docs-start collapse-classes\n.collapse {\n &:not(.show) {\n display: none;\n }\n}\n\n.collapsing {\n height: 0;\n overflow: hidden;\n @include transition($transition-collapse);\n\n &.collapse-horizontal {\n width: 0;\n height: auto;\n @include transition($transition-collapse-width);\n }\n}\n// scss-docs-end collapse-classes\n","// The dropdown wrapper (`<div>`)\n.dropup,\n.dropend,\n.dropdown,\n.dropstart {\n position: relative;\n}\n\n.dropdown-toggle {\n white-space: nowrap;\n\n // Generate the caret automatically\n @include caret();\n}\n\n// The dropdown menu\n.dropdown-menu {\n position: absolute;\n z-index: $zindex-dropdown;\n display: none; // none by default, but block on \"open\" of the menu\n min-width: $dropdown-min-width;\n padding: $dropdown-padding-y $dropdown-padding-x;\n margin: 0; // Override default margin of ul\n @include font-size($dropdown-font-size);\n color: $dropdown-color;\n text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)\n list-style: none;\n background-color: $dropdown-bg;\n background-clip: padding-box;\n border: $dropdown-border-width solid $dropdown-border-color;\n @include border-radius($dropdown-border-radius);\n @include box-shadow($dropdown-box-shadow);\n\n &[data-bs-popper] {\n top: 100%;\n left: 0;\n margin-top: $dropdown-spacer;\n }\n}\n\n// scss-docs-start responsive-breakpoints\n// We deliberately hardcode the `bs-` prefix because we check\n// this custom property in JS to determine Popper's positioning\n\n@each $breakpoint in map-keys($grid-breakpoints) {\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n .dropdown-menu#{$infix}-start {\n --bs-position: start;\n\n &[data-bs-popper] {\n right: auto;\n left: 0;\n }\n }\n\n .dropdown-menu#{$infix}-end {\n --bs-position: end;\n\n &[data-bs-popper] {\n right: 0;\n left: auto;\n }\n }\n }\n}\n// scss-docs-end responsive-breakpoints\n\n// Allow for dropdowns to go bottom up (aka, dropup-menu)\n// Just add .dropup after the standard .dropdown class and you're set.\n.dropup {\n .dropdown-menu[data-bs-popper] {\n top: auto;\n bottom: 100%;\n margin-top: 0;\n margin-bottom: $dropdown-spacer;\n }\n\n .dropdown-toggle {\n @include caret(up);\n }\n}\n\n.dropend {\n .dropdown-menu[data-bs-popper] {\n top: 0;\n right: auto;\n left: 100%;\n margin-top: 0;\n margin-left: $dropdown-spacer;\n }\n\n .dropdown-toggle {\n @include caret(end);\n &::after {\n vertical-align: 0;\n }\n }\n}\n\n.dropstart {\n .dropdown-menu[data-bs-popper] {\n top: 0;\n right: 100%;\n left: auto;\n margin-top: 0;\n margin-right: $dropdown-spacer;\n }\n\n .dropdown-toggle {\n @include caret(start);\n &::before {\n vertical-align: 0;\n }\n }\n}\n\n\n// Dividers (basically an `<hr>`) within the dropdown\n.dropdown-divider {\n height: 0;\n margin: $dropdown-divider-margin-y 0;\n overflow: hidden;\n border-top: 1px solid $dropdown-divider-bg;\n}\n\n// Links, buttons, and more within the dropdown menu\n//\n// `<button>`-specific styles are denoted with `// For <button>s`\n.dropdown-item {\n display: block;\n width: 100%; // For `<button>`s\n padding: $dropdown-item-padding-y $dropdown-item-padding-x;\n clear: both;\n font-weight: $font-weight-normal;\n color: $dropdown-link-color;\n text-align: inherit; // For `<button>`s\n text-decoration: if($link-decoration == none, null, none);\n white-space: nowrap; // prevent links from randomly breaking onto new lines\n background-color: transparent; // For `<button>`s\n border: 0; // For `<button>`s\n\n // Prevent dropdown overflow if there's no padding\n // See https://github.com/twbs/bootstrap/pull/27703\n @if $dropdown-padding-y == 0 {\n &:first-child {\n @include border-top-radius($dropdown-inner-border-radius);\n }\n\n &:last-child {\n @include border-bottom-radius($dropdown-inner-border-radius);\n }\n }\n\n &:hover,\n &:focus {\n color: $dropdown-link-hover-color;\n text-decoration: if($link-hover-decoration == underline, none, null);\n @include gradient-bg($dropdown-link-hover-bg);\n }\n\n &.active,\n &:active {\n color: $dropdown-link-active-color;\n text-decoration: none;\n @include gradient-bg($dropdown-link-active-bg);\n }\n\n &.disabled,\n &:disabled {\n color: $dropdown-link-disabled-color;\n pointer-events: none;\n background-color: transparent;\n // Remove CSS gradients if they're enabled\n background-image: if($enable-gradients, none, null);\n }\n}\n\n.dropdown-menu.show {\n display: block;\n}\n\n// Dropdown section headers\n.dropdown-header {\n display: block;\n padding: $dropdown-header-padding;\n margin-bottom: 0; // for use with heading elements\n @include font-size($font-size-sm);\n color: $dropdown-header-color;\n white-space: nowrap; // as with > li > a\n}\n\n// Dropdown text\n.dropdown-item-text {\n display: block;\n padding: $dropdown-item-padding-y $dropdown-item-padding-x;\n color: $dropdown-link-color;\n}\n\n// Dark dropdowns\n.dropdown-menu-dark {\n color: $dropdown-dark-color;\n background-color: $dropdown-dark-bg;\n border-color: $dropdown-dark-border-color;\n @include box-shadow($dropdown-dark-box-shadow);\n\n .dropdown-item {\n color: $dropdown-dark-link-color;\n\n &:hover,\n &:focus {\n color: $dropdown-dark-link-hover-color;\n @include gradient-bg($dropdown-dark-link-hover-bg);\n }\n\n &.active,\n &:active {\n color: $dropdown-dark-link-active-color;\n @include gradient-bg($dropdown-dark-link-active-bg);\n }\n\n &.disabled,\n &:disabled {\n color: $dropdown-dark-link-disabled-color;\n }\n }\n\n .dropdown-divider {\n border-color: $dropdown-dark-divider-bg;\n }\n\n .dropdown-item-text {\n color: $dropdown-dark-link-color;\n }\n\n .dropdown-header {\n color: $dropdown-dark-header-color;\n }\n}\n","// scss-docs-start caret-mixins\n@mixin caret-down {\n border-top: $caret-width solid;\n border-right: $caret-width solid transparent;\n border-bottom: 0;\n border-left: $caret-width solid transparent;\n}\n\n@mixin caret-up {\n border-top: 0;\n border-right: $caret-width solid transparent;\n border-bottom: $caret-width solid;\n border-left: $caret-width solid transparent;\n}\n\n@mixin caret-end {\n border-top: $caret-width solid transparent;\n border-right: 0;\n border-bottom: $caret-width solid transparent;\n border-left: $caret-width solid;\n}\n\n@mixin caret-start {\n border-top: $caret-width solid transparent;\n border-right: $caret-width solid;\n border-bottom: $caret-width solid transparent;\n}\n\n@mixin caret($direction: down) {\n @if $enable-caret {\n &::after {\n display: inline-block;\n margin-left: $caret-spacing;\n vertical-align: $caret-vertical-align;\n content: \"\";\n @if $direction == down {\n @include caret-down();\n } @else if $direction == up {\n @include caret-up();\n } @else if $direction == end {\n @include caret-end();\n }\n }\n\n @if $direction == start {\n &::after {\n display: none;\n }\n\n &::before {\n display: inline-block;\n margin-right: $caret-spacing;\n vertical-align: $caret-vertical-align;\n content: \"\";\n @include caret-start();\n }\n }\n\n &:empty::after {\n margin-left: 0;\n }\n }\n}\n// scss-docs-end caret-mixins\n","// Make the div behave like a button\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-flex;\n vertical-align: middle; // match .btn alignment given font-size hack above\n\n > .btn {\n position: relative;\n flex: 1 1 auto;\n }\n\n // Bring the hover, focused, and \"active\" buttons to the front to overlay\n // the borders properly\n > .btn-check:checked + .btn,\n > .btn-check:focus + .btn,\n > .btn:hover,\n > .btn:focus,\n > .btn:active,\n > .btn.active {\n z-index: 1;\n }\n}\n\n// Optional: Group multiple button groups together for a toolbar\n.btn-toolbar {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-start;\n\n .input-group {\n width: auto;\n }\n}\n\n.btn-group {\n // Prevent double borders when buttons are next to each other\n > .btn:not(:first-child),\n > .btn-group:not(:first-child) {\n margin-left: -$btn-border-width;\n }\n\n // Reset rounded corners\n > .btn:not(:last-child):not(.dropdown-toggle),\n > .btn-group:not(:last-child) > .btn {\n @include border-end-radius(0);\n }\n\n // The left radius should be 0 if the button is:\n // - the \"third or more\" child\n // - the second child and the previous element isn't `.btn-check` (making it the first child visually)\n // - part of a btn-group which isn't the first child\n > .btn:nth-child(n + 3),\n > :not(.btn-check) + .btn,\n > .btn-group:not(:first-child) > .btn {\n @include border-start-radius(0);\n }\n}\n\n// Sizing\n//\n// Remix the default button sizing classes into new ones for easier manipulation.\n\n.btn-group-sm > .btn { @extend .btn-sm; }\n.btn-group-lg > .btn { @extend .btn-lg; }\n\n\n//\n// Split button dropdowns\n//\n\n.dropdown-toggle-split {\n padding-right: $btn-padding-x * .75;\n padding-left: $btn-padding-x * .75;\n\n &::after,\n .dropup &::after,\n .dropend &::after {\n margin-left: 0;\n }\n\n .dropstart &::before {\n margin-right: 0;\n }\n}\n\n.btn-sm + .dropdown-toggle-split {\n padding-right: $btn-padding-x-sm * .75;\n padding-left: $btn-padding-x-sm * .75;\n}\n\n.btn-lg + .dropdown-toggle-split {\n padding-right: $btn-padding-x-lg * .75;\n padding-left: $btn-padding-x-lg * .75;\n}\n\n\n// The clickable button for toggling the menu\n// Set the same inset shadow as the :active state\n.btn-group.show .dropdown-toggle {\n @include box-shadow($btn-active-box-shadow);\n\n // Show no shadow for `.btn-link` since it has no other button styles.\n &.btn-link {\n @include box-shadow(none);\n }\n}\n\n\n//\n// Vertical button groups\n//\n\n.btn-group-vertical {\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n\n > .btn,\n > .btn-group {\n width: 100%;\n }\n\n > .btn:not(:first-child),\n > .btn-group:not(:first-child) {\n margin-top: -$btn-border-width;\n }\n\n // Reset rounded corners\n > .btn:not(:last-child):not(.dropdown-toggle),\n > .btn-group:not(:last-child) > .btn {\n @include border-bottom-radius(0);\n }\n\n > .btn ~ .btn,\n > .btn-group:not(:first-child) > .btn {\n @include border-top-radius(0);\n }\n}\n","// Base class\n//\n// Kickstart any navigation component with a set of style resets. Works with\n// `<nav>`s, `<ul>`s or `<ol>`s.\n\n.nav {\n display: flex;\n flex-wrap: wrap;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.nav-link {\n display: block;\n padding: $nav-link-padding-y $nav-link-padding-x;\n @include font-size($nav-link-font-size);\n font-weight: $nav-link-font-weight;\n color: $nav-link-color;\n text-decoration: if($link-decoration == none, null, none);\n @include transition($nav-link-transition);\n\n &:hover,\n &:focus {\n color: $nav-link-hover-color;\n text-decoration: if($link-hover-decoration == underline, none, null);\n }\n\n // Disabled state lightens text\n &.disabled {\n color: $nav-link-disabled-color;\n pointer-events: none;\n cursor: default;\n }\n}\n\n//\n// Tabs\n//\n\n.nav-tabs {\n border-bottom: $nav-tabs-border-width solid $nav-tabs-border-color;\n\n .nav-link {\n margin-bottom: -$nav-tabs-border-width;\n background: none;\n border: $nav-tabs-border-width solid transparent;\n @include border-top-radius($nav-tabs-border-radius);\n\n &:hover,\n &:focus {\n border-color: $nav-tabs-link-hover-border-color;\n // Prevents active .nav-link tab overlapping focus outline of previous/next .nav-link\n isolation: isolate;\n }\n\n &.disabled {\n color: $nav-link-disabled-color;\n background-color: transparent;\n border-color: transparent;\n }\n }\n\n .nav-link.active,\n .nav-item.show .nav-link {\n color: $nav-tabs-link-active-color;\n background-color: $nav-tabs-link-active-bg;\n border-color: $nav-tabs-link-active-border-color;\n }\n\n .dropdown-menu {\n // Make dropdown border overlap tab border\n margin-top: -$nav-tabs-border-width;\n // Remove the top rounded corners here since there is a hard edge above the menu\n @include border-top-radius(0);\n }\n}\n\n\n//\n// Pills\n//\n\n.nav-pills {\n .nav-link {\n background: none;\n border: 0;\n @include border-radius($nav-pills-border-radius);\n }\n\n .nav-link.active,\n .show > .nav-link {\n color: $nav-pills-link-active-color;\n @include gradient-bg($nav-pills-link-active-bg);\n }\n}\n\n\n//\n// Justified variants\n//\n\n.nav-fill {\n > .nav-link,\n .nav-item {\n flex: 1 1 auto;\n text-align: center;\n }\n}\n\n.nav-justified {\n > .nav-link,\n .nav-item {\n flex-basis: 0;\n flex-grow: 1;\n text-align: center;\n }\n}\n\n.nav-fill,\n.nav-justified {\n .nav-item .nav-link {\n width: 100%; // Make sure button will grow\n }\n}\n\n\n// Tabbable tabs\n//\n// Hide tabbable panes to start, show them when `.active`\n\n.tab-content {\n > .tab-pane {\n display: none;\n }\n > .active {\n display: block;\n }\n}\n","// Contents\n//\n// Navbar\n// Navbar brand\n// Navbar nav\n// Navbar text\n// Responsive navbar\n// Navbar position\n// Navbar themes\n\n\n// Navbar\n//\n// Provide a static navbar from which we expand to create full-width, fixed, and\n// other navbar variations.\n\n.navbar {\n position: relative;\n display: flex;\n flex-wrap: wrap; // allow us to do the line break for collapsing content\n align-items: center;\n justify-content: space-between; // space out brand from logo\n padding-top: $navbar-padding-y;\n padding-right: $navbar-padding-x; // default: null\n padding-bottom: $navbar-padding-y;\n padding-left: $navbar-padding-x; // default: null\n @include gradient-bg();\n\n // Because flex properties aren't inherited, we need to redeclare these first\n // few properties so that content nested within behave properly.\n // The `flex-wrap` property is inherited to simplify the expanded navbars\n %container-flex-properties {\n display: flex;\n flex-wrap: inherit;\n align-items: center;\n justify-content: space-between;\n }\n\n > .container,\n > .container-fluid {\n @extend %container-flex-properties;\n }\n\n @each $breakpoint, $container-max-width in $container-max-widths {\n > .container#{breakpoint-infix($breakpoint, $container-max-widths)} {\n @extend %container-flex-properties;\n }\n }\n}\n\n\n// Navbar brand\n//\n// Used for brand, project, or site names.\n\n.navbar-brand {\n padding-top: $navbar-brand-padding-y;\n padding-bottom: $navbar-brand-padding-y;\n margin-right: $navbar-brand-margin-end;\n @include font-size($navbar-brand-font-size);\n text-decoration: if($link-decoration == none, null, none);\n white-space: nowrap;\n\n &:hover,\n &:focus {\n text-decoration: if($link-hover-decoration == underline, none, null);\n }\n}\n\n\n// Navbar nav\n//\n// Custom navbar navigation (doesn't require `.nav`, but does make use of `.nav-link`).\n\n.navbar-nav {\n display: flex;\n flex-direction: column; // cannot use `inherit` to get the `.navbar`s value\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n\n .nav-link {\n padding-right: 0;\n padding-left: 0;\n }\n\n .dropdown-menu {\n position: static;\n }\n}\n\n\n// Navbar text\n//\n//\n\n.navbar-text {\n padding-top: $nav-link-padding-y;\n padding-bottom: $nav-link-padding-y;\n}\n\n\n// Responsive navbar\n//\n// Custom styles for responsive collapsing and toggling of navbar contents.\n// Powered by the collapse Bootstrap JavaScript plugin.\n\n// When collapsed, prevent the toggleable navbar contents from appearing in\n// the default flexbox row orientation. Requires the use of `flex-wrap: wrap`\n// on the `.navbar` parent.\n.navbar-collapse {\n flex-basis: 100%;\n flex-grow: 1;\n // For always expanded or extra full navbars, ensure content aligns itself\n // properly vertically. Can be easily overridden with flex utilities.\n align-items: center;\n}\n\n// Button for toggling the navbar when in its collapsed state\n.navbar-toggler {\n padding: $navbar-toggler-padding-y $navbar-toggler-padding-x;\n @include font-size($navbar-toggler-font-size);\n line-height: 1;\n background-color: transparent; // remove default button style\n border: $border-width solid transparent; // remove default button style\n @include border-radius($navbar-toggler-border-radius);\n @include transition($navbar-toggler-transition);\n\n &:hover {\n text-decoration: none;\n }\n\n &:focus {\n text-decoration: none;\n outline: 0;\n box-shadow: 0 0 0 $navbar-toggler-focus-width;\n }\n}\n\n// Keep as a separate element so folks can easily override it with another icon\n// or image file as needed.\n.navbar-toggler-icon {\n display: inline-block;\n width: 1.5em;\n height: 1.5em;\n vertical-align: middle;\n background-repeat: no-repeat;\n background-position: center;\n background-size: 100%;\n}\n\n.navbar-nav-scroll {\n max-height: var(--#{$variable-prefix}scroll-height, 75vh);\n overflow-y: auto;\n}\n\n// scss-docs-start navbar-expand-loop\n// Generate series of `.navbar-expand-*` responsive classes for configuring\n// where your navbar collapses.\n.navbar-expand {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $next: breakpoint-next($breakpoint, $grid-breakpoints);\n $infix: breakpoint-infix($next, $grid-breakpoints);\n\n // stylelint-disable-next-line scss/selector-no-union-class-name\n &#{$infix} {\n @include media-breakpoint-up($next) {\n flex-wrap: nowrap;\n justify-content: flex-start;\n\n .navbar-nav {\n flex-direction: row;\n\n .dropdown-menu {\n position: absolute;\n }\n\n .nav-link {\n padding-right: $navbar-nav-link-padding-x;\n padding-left: $navbar-nav-link-padding-x;\n }\n }\n\n .navbar-nav-scroll {\n overflow: visible;\n }\n\n .navbar-collapse {\n display: flex !important; // stylelint-disable-line declaration-no-important\n flex-basis: auto;\n }\n\n .navbar-toggler {\n display: none;\n }\n\n .offcanvas-header {\n display: none;\n }\n\n .offcanvas {\n position: inherit;\n bottom: 0;\n z-index: 1000;\n flex-grow: 1;\n visibility: visible !important; // stylelint-disable-line declaration-no-important\n background-color: transparent;\n border-right: 0;\n border-left: 0;\n @include transition(none);\n transform: none;\n }\n .offcanvas-top,\n .offcanvas-bottom {\n height: auto;\n border-top: 0;\n border-bottom: 0;\n }\n\n .offcanvas-body {\n display: flex;\n flex-grow: 0;\n padding: 0;\n overflow-y: visible;\n }\n }\n }\n }\n}\n// scss-docs-end navbar-expand-loop\n\n// Navbar themes\n//\n// Styles for switching between navbars with light or dark background.\n\n// Dark links against a light background\n.navbar-light {\n .navbar-brand {\n color: $navbar-light-brand-color;\n\n &:hover,\n &:focus {\n color: $navbar-light-brand-hover-color;\n }\n }\n\n .navbar-nav {\n .nav-link {\n color: $navbar-light-color;\n\n &:hover,\n &:focus {\n color: $navbar-light-hover-color;\n }\n\n &.disabled {\n color: $navbar-light-disabled-color;\n }\n }\n\n .show > .nav-link,\n .nav-link.active {\n color: $navbar-light-active-color;\n }\n }\n\n .navbar-toggler {\n color: $navbar-light-color;\n border-color: $navbar-light-toggler-border-color;\n }\n\n .navbar-toggler-icon {\n background-image: escape-svg($navbar-light-toggler-icon-bg);\n }\n\n .navbar-text {\n color: $navbar-light-color;\n\n a,\n a:hover,\n a:focus {\n color: $navbar-light-active-color;\n }\n }\n}\n\n// White links against a dark background\n.navbar-dark {\n .navbar-brand {\n color: $navbar-dark-brand-color;\n\n &:hover,\n &:focus {\n color: $navbar-dark-brand-hover-color;\n }\n }\n\n .navbar-nav {\n .nav-link {\n color: $navbar-dark-color;\n\n &:hover,\n &:focus {\n color: $navbar-dark-hover-color;\n }\n\n &.disabled {\n color: $navbar-dark-disabled-color;\n }\n }\n\n .show > .nav-link,\n .nav-link.active {\n color: $navbar-dark-active-color;\n }\n }\n\n .navbar-toggler {\n color: $navbar-dark-color;\n border-color: $navbar-dark-toggler-border-color;\n }\n\n .navbar-toggler-icon {\n background-image: escape-svg($navbar-dark-toggler-icon-bg);\n }\n\n .navbar-text {\n color: $navbar-dark-color;\n a,\n a:hover,\n a:focus {\n color: $navbar-dark-active-color;\n }\n }\n}\n","//\n// Base styles\n//\n\n.card {\n position: relative;\n display: flex;\n flex-direction: column;\n min-width: 0; // See https://github.com/twbs/bootstrap/pull/22740#issuecomment-305868106\n height: $card-height;\n word-wrap: break-word;\n background-color: $card-bg;\n background-clip: border-box;\n border: $card-border-width solid $card-border-color;\n @include border-radius($card-border-radius);\n @include box-shadow($card-box-shadow);\n\n > hr {\n margin-right: 0;\n margin-left: 0;\n }\n\n > .list-group {\n border-top: inherit;\n border-bottom: inherit;\n\n &:first-child {\n border-top-width: 0;\n @include border-top-radius($card-inner-border-radius);\n }\n\n &:last-child {\n border-bottom-width: 0;\n @include border-bottom-radius($card-inner-border-radius);\n }\n }\n\n // Due to specificity of the above selector (`.card > .list-group`), we must\n // use a child selector here to prevent double borders.\n > .card-header + .list-group,\n > .list-group + .card-footer {\n border-top: 0;\n }\n}\n\n.card-body {\n // Enable `flex-grow: 1` for decks and groups so that card blocks take up\n // as much space as possible, ensuring footers are aligned to the bottom.\n flex: 1 1 auto;\n padding: $card-spacer-y $card-spacer-x;\n color: $card-color;\n}\n\n.card-title {\n margin-bottom: $card-title-spacer-y;\n}\n\n.card-subtitle {\n margin-top: -$card-title-spacer-y * .5;\n margin-bottom: 0;\n}\n\n.card-text:last-child {\n margin-bottom: 0;\n}\n\n.card-link {\n &:hover {\n text-decoration: if($link-hover-decoration == underline, none, null);\n }\n\n + .card-link {\n margin-left: $card-spacer-x;\n }\n}\n\n//\n// Optional textual caps\n//\n\n.card-header {\n padding: $card-cap-padding-y $card-cap-padding-x;\n margin-bottom: 0; // Removes the default margin-bottom of <hN>\n color: $card-cap-color;\n background-color: $card-cap-bg;\n border-bottom: $card-border-width solid $card-border-color;\n\n &:first-child {\n @include border-radius($card-inner-border-radius $card-inner-border-radius 0 0);\n }\n}\n\n.card-footer {\n padding: $card-cap-padding-y $card-cap-padding-x;\n color: $card-cap-color;\n background-color: $card-cap-bg;\n border-top: $card-border-width solid $card-border-color;\n\n &:last-child {\n @include border-radius(0 0 $card-inner-border-radius $card-inner-border-radius);\n }\n}\n\n\n//\n// Header navs\n//\n\n.card-header-tabs {\n margin-right: -$card-cap-padding-x * .5;\n margin-bottom: -$card-cap-padding-y;\n margin-left: -$card-cap-padding-x * .5;\n border-bottom: 0;\n\n @if $nav-tabs-link-active-bg != $card-bg {\n .nav-link.active {\n background-color: $card-bg;\n border-bottom-color: $card-bg;\n }\n }\n}\n\n.card-header-pills {\n margin-right: -$card-cap-padding-x * .5;\n margin-left: -$card-cap-padding-x * .5;\n}\n\n// Card image\n.card-img-overlay {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n padding: $card-img-overlay-padding;\n @include border-radius($card-inner-border-radius);\n}\n\n.card-img,\n.card-img-top,\n.card-img-bottom {\n width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch\n}\n\n.card-img,\n.card-img-top {\n @include border-top-radius($card-inner-border-radius);\n}\n\n.card-img,\n.card-img-bottom {\n @include border-bottom-radius($card-inner-border-radius);\n}\n\n\n//\n// Card groups\n//\n\n.card-group {\n // The child selector allows nested `.card` within `.card-group`\n // to display properly.\n > .card {\n margin-bottom: $card-group-margin;\n }\n\n @include media-breakpoint-up(sm) {\n display: flex;\n flex-flow: row wrap;\n // The child selector allows nested `.card` within `.card-group`\n // to display properly.\n > .card {\n // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n flex: 1 0 0%;\n margin-bottom: 0;\n\n + .card {\n margin-left: 0;\n border-left: 0;\n }\n\n // Handle rounded corners\n @if $enable-rounded {\n &:not(:last-child) {\n @include border-end-radius(0);\n\n .card-img-top,\n .card-header {\n // stylelint-disable-next-line property-disallowed-list\n border-top-right-radius: 0;\n }\n .card-img-bottom,\n .card-footer {\n // stylelint-disable-next-line property-disallowed-list\n border-bottom-right-radius: 0;\n }\n }\n\n &:not(:first-child) {\n @include border-start-radius(0);\n\n .card-img-top,\n .card-header {\n // stylelint-disable-next-line property-disallowed-list\n border-top-left-radius: 0;\n }\n .card-img-bottom,\n .card-footer {\n // stylelint-disable-next-line property-disallowed-list\n border-bottom-left-radius: 0;\n }\n }\n }\n }\n }\n}\n","//\n// Base styles\n//\n\n.accordion-button {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n padding: $accordion-button-padding-y $accordion-button-padding-x;\n @include font-size($font-size-base);\n color: $accordion-button-color;\n text-align: left; // Reset button style\n background-color: $accordion-button-bg;\n border: 0;\n @include border-radius(0);\n overflow-anchor: none;\n @include transition($accordion-transition);\n\n &:not(.collapsed) {\n color: $accordion-button-active-color;\n background-color: $accordion-button-active-bg;\n box-shadow: inset 0 ($accordion-border-width * -1) 0 $accordion-border-color;\n\n &::after {\n background-image: escape-svg($accordion-button-active-icon);\n transform: $accordion-icon-transform;\n }\n }\n\n // Accordion icon\n &::after {\n flex-shrink: 0;\n width: $accordion-icon-width;\n height: $accordion-icon-width;\n margin-left: auto;\n content: \"\";\n background-image: escape-svg($accordion-button-icon);\n background-repeat: no-repeat;\n background-size: $accordion-icon-width;\n @include transition($accordion-icon-transition);\n }\n\n &:hover {\n z-index: 2;\n }\n\n &:focus {\n z-index: 3;\n border-color: $accordion-button-focus-border-color;\n outline: 0;\n box-shadow: $accordion-button-focus-box-shadow;\n }\n}\n\n.accordion-header {\n margin-bottom: 0;\n}\n\n.accordion-item {\n background-color: $accordion-bg;\n border: $accordion-border-width solid $accordion-border-color;\n\n &:first-of-type {\n @include border-top-radius($accordion-border-radius);\n\n .accordion-button {\n @include border-top-radius($accordion-inner-border-radius);\n }\n }\n\n &:not(:first-of-type) {\n border-top: 0;\n }\n\n // Only set a border-radius on the last item if the accordion is collapsed\n &:last-of-type {\n @include border-bottom-radius($accordion-border-radius);\n\n .accordion-button {\n &.collapsed {\n @include border-bottom-radius($accordion-inner-border-radius);\n }\n }\n\n .accordion-collapse {\n @include border-bottom-radius($accordion-border-radius);\n }\n }\n}\n\n.accordion-body {\n padding: $accordion-body-padding-y $accordion-body-padding-x;\n}\n\n\n// Flush accordion items\n//\n// Remove borders and border-radius to keep accordion items edge-to-edge.\n\n.accordion-flush {\n .accordion-collapse {\n border-width: 0;\n }\n\n .accordion-item {\n border-right: 0;\n border-left: 0;\n @include border-radius(0);\n\n &:first-child { border-top: 0; }\n &:last-child { border-bottom: 0; }\n\n .accordion-button {\n @include border-radius(0);\n }\n }\n}\n",".breadcrumb {\n display: flex;\n flex-wrap: wrap;\n padding: $breadcrumb-padding-y $breadcrumb-padding-x;\n margin-bottom: $breadcrumb-margin-bottom;\n @include font-size($breadcrumb-font-size);\n list-style: none;\n background-color: $breadcrumb-bg;\n @include border-radius($breadcrumb-border-radius);\n}\n\n.breadcrumb-item {\n // The separator between breadcrumbs (by default, a forward-slash: \"/\")\n + .breadcrumb-item {\n padding-left: $breadcrumb-item-padding-x;\n\n &::before {\n float: left; // Suppress inline spacings and underlining of the separator\n padding-right: $breadcrumb-item-padding-x;\n color: $breadcrumb-divider-color;\n content: var(--#{$variable-prefix}breadcrumb-divider, escape-svg($breadcrumb-divider)) #{\"/* rtl:\"} var(--#{$variable-prefix}breadcrumb-divider, escape-svg($breadcrumb-divider-flipped)) #{\"*/\"};\n }\n }\n\n &.active {\n color: $breadcrumb-active-color;\n }\n}\n",".pagination {\n display: flex;\n @include list-unstyled();\n}\n\n.page-link {\n position: relative;\n display: block;\n color: $pagination-color;\n text-decoration: if($link-decoration == none, null, none);\n background-color: $pagination-bg;\n border: $pagination-border-width solid $pagination-border-color;\n @include transition($pagination-transition);\n\n &:hover {\n z-index: 2;\n color: $pagination-hover-color;\n text-decoration: if($link-hover-decoration == underline, none, null);\n background-color: $pagination-hover-bg;\n border-color: $pagination-hover-border-color;\n }\n\n &:focus {\n z-index: 3;\n color: $pagination-focus-color;\n background-color: $pagination-focus-bg;\n outline: $pagination-focus-outline;\n box-shadow: $pagination-focus-box-shadow;\n }\n}\n\n.page-item {\n &:not(:first-child) .page-link {\n margin-left: $pagination-margin-start;\n }\n\n &.active .page-link {\n z-index: 3;\n color: $pagination-active-color;\n @include gradient-bg($pagination-active-bg);\n border-color: $pagination-active-border-color;\n }\n\n &.disabled .page-link {\n color: $pagination-disabled-color;\n pointer-events: none;\n background-color: $pagination-disabled-bg;\n border-color: $pagination-disabled-border-color;\n }\n}\n\n\n//\n// Sizing\n//\n@include pagination-size($pagination-padding-y, $pagination-padding-x, null, $pagination-border-radius);\n\n.pagination-lg {\n @include pagination-size($pagination-padding-y-lg, $pagination-padding-x-lg, $font-size-lg, $pagination-border-radius-lg);\n}\n\n.pagination-sm {\n @include pagination-size($pagination-padding-y-sm, $pagination-padding-x-sm, $font-size-sm, $pagination-border-radius-sm);\n}\n","// Pagination\n\n// scss-docs-start pagination-mixin\n@mixin pagination-size($padding-y, $padding-x, $font-size, $border-radius) {\n .page-link {\n padding: $padding-y $padding-x;\n @include font-size($font-size);\n }\n\n .page-item {\n @if $pagination-margin-start == (-$pagination-border-width) {\n &:first-child {\n .page-link {\n @include border-start-radius($border-radius);\n }\n }\n\n &:last-child {\n .page-link {\n @include border-end-radius($border-radius);\n }\n }\n } @else {\n //Add border-radius to all pageLinks in case they have left margin\n .page-link {\n @include border-radius($border-radius);\n }\n }\n }\n}\n// scss-docs-end pagination-mixin\n","// Base class\n//\n// Requires one of the contextual, color modifier classes for `color` and\n// `background-color`.\n\n.badge {\n display: inline-block;\n padding: $badge-padding-y $badge-padding-x;\n @include font-size($badge-font-size);\n font-weight: $badge-font-weight;\n line-height: 1;\n color: $badge-color;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n @include border-radius($badge-border-radius);\n @include gradient-bg();\n\n // Empty badges collapse automatically\n &:empty {\n display: none;\n }\n}\n\n// Quick fix for badges in buttons\n.btn .badge {\n position: relative;\n top: -1px;\n}\n","//\n// Base styles\n//\n\n.alert {\n position: relative;\n padding: $alert-padding-y $alert-padding-x;\n margin-bottom: $alert-margin-bottom;\n border: $alert-border-width solid transparent;\n @include border-radius($alert-border-radius);\n}\n\n// Headings for larger alerts\n.alert-heading {\n // Specified to prevent conflicts of changing $headings-color\n color: inherit;\n}\n\n// Provide class for links that match alerts\n.alert-link {\n font-weight: $alert-link-font-weight;\n}\n\n\n// Dismissible alerts\n//\n// Expand the right padding and account for the close button's positioning.\n\n.alert-dismissible {\n padding-right: $alert-dismissible-padding-r;\n\n // Adjust close link position\n .btn-close {\n position: absolute;\n top: 0;\n right: 0;\n z-index: $stretched-link-z-index + 1;\n padding: $alert-padding-y * 1.25 $alert-padding-x;\n }\n}\n\n\n// scss-docs-start alert-modifiers\n// Generate contextual modifier classes for colorizing the alert.\n\n@each $state, $value in $theme-colors {\n $alert-background: shift-color($value, $alert-bg-scale);\n $alert-border: shift-color($value, $alert-border-scale);\n $alert-color: shift-color($value, $alert-color-scale);\n @if (contrast-ratio($alert-background, $alert-color) < $min-contrast-ratio) {\n $alert-color: mix($value, color-contrast($alert-background), abs($alert-color-scale));\n }\n .alert-#{$state} {\n @include alert-variant($alert-background, $alert-border, $alert-color);\n }\n}\n// scss-docs-end alert-modifiers\n","// scss-docs-start alert-variant-mixin\n@mixin alert-variant($background, $border, $color) {\n color: $color;\n @include gradient-bg($background);\n border-color: $border;\n\n .alert-link {\n color: shade-color($color, 20%);\n }\n}\n// scss-docs-end alert-variant-mixin\n","// Disable animation if transitions are disabled\n\n// scss-docs-start progress-keyframes\n@if $enable-transitions {\n @keyframes progress-bar-stripes {\n 0% { background-position-x: $progress-height; }\n }\n}\n// scss-docs-end progress-keyframes\n\n.progress {\n display: flex;\n height: $progress-height;\n overflow: hidden; // force rounded corners by cropping it\n @include font-size($progress-font-size);\n background-color: $progress-bg;\n @include border-radius($progress-border-radius);\n @include box-shadow($progress-box-shadow);\n}\n\n.progress-bar {\n display: flex;\n flex-direction: column;\n justify-content: center;\n overflow: hidden;\n color: $progress-bar-color;\n text-align: center;\n white-space: nowrap;\n background-color: $progress-bar-bg;\n @include transition($progress-bar-transition);\n}\n\n.progress-bar-striped {\n @include gradient-striped();\n background-size: $progress-height $progress-height;\n}\n\n@if $enable-transitions {\n .progress-bar-animated {\n animation: $progress-bar-animation-timing progress-bar-stripes;\n\n @if $enable-reduced-motion {\n @media (prefers-reduced-motion: reduce) {\n animation: none;\n }\n }\n }\n}\n","// Base class\n//\n// Easily usable on <ul>, <ol>, or <div>.\n\n.list-group {\n display: flex;\n flex-direction: column;\n\n // No need to set list-style: none; since .list-group-item is block level\n padding-left: 0; // reset padding because ul and ol\n margin-bottom: 0;\n @include border-radius($list-group-border-radius);\n}\n\n.list-group-numbered {\n list-style-type: none;\n counter-reset: section;\n\n > li::before {\n // Increments only this instance of the section counter\n content: counters(section, \".\") \". \";\n counter-increment: section;\n }\n}\n\n\n// Interactive list items\n//\n// Use anchor or button elements instead of `li`s or `div`s to create interactive\n// list items. Includes an extra `.active` modifier class for selected items.\n\n.list-group-item-action {\n width: 100%; // For `<button>`s (anchors become 100% by default though)\n color: $list-group-action-color;\n text-align: inherit; // For `<button>`s (anchors inherit)\n\n // Hover state\n &:hover,\n &:focus {\n z-index: 1; // Place hover/focus items above their siblings for proper border styling\n color: $list-group-action-hover-color;\n text-decoration: none;\n background-color: $list-group-hover-bg;\n }\n\n &:active {\n color: $list-group-action-active-color;\n background-color: $list-group-action-active-bg;\n }\n}\n\n\n// Individual list items\n//\n// Use on `li`s or `div`s within the `.list-group` parent.\n\n.list-group-item {\n position: relative;\n display: block;\n padding: $list-group-item-padding-y $list-group-item-padding-x;\n color: $list-group-color;\n text-decoration: if($link-decoration == none, null, none);\n background-color: $list-group-bg;\n border: $list-group-border-width solid $list-group-border-color;\n\n &:first-child {\n @include border-top-radius(inherit);\n }\n\n &:last-child {\n @include border-bottom-radius(inherit);\n }\n\n &.disabled,\n &:disabled {\n color: $list-group-disabled-color;\n pointer-events: none;\n background-color: $list-group-disabled-bg;\n }\n\n // Include both here for `<a>`s and `<button>`s\n &.active {\n z-index: 2; // Place active items above their siblings for proper border styling\n color: $list-group-active-color;\n background-color: $list-group-active-bg;\n border-color: $list-group-active-border-color;\n }\n\n & + & {\n border-top-width: 0;\n\n &.active {\n margin-top: -$list-group-border-width;\n border-top-width: $list-group-border-width;\n }\n }\n}\n\n\n// Horizontal\n//\n// Change the layout of list group items from vertical (default) to horizontal.\n\n@each $breakpoint in map-keys($grid-breakpoints) {\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n .list-group-horizontal#{$infix} {\n flex-direction: row;\n\n > .list-group-item {\n &:first-child {\n @include border-bottom-start-radius($list-group-border-radius);\n @include border-top-end-radius(0);\n }\n\n &:last-child {\n @include border-top-end-radius($list-group-border-radius);\n @include border-bottom-start-radius(0);\n }\n\n &.active {\n margin-top: 0;\n }\n\n + .list-group-item {\n border-top-width: $list-group-border-width;\n border-left-width: 0;\n\n &.active {\n margin-left: -$list-group-border-width;\n border-left-width: $list-group-border-width;\n }\n }\n }\n }\n }\n}\n\n\n// Flush list items\n//\n// Remove borders and border-radius to keep list group items edge-to-edge. Most\n// useful within other components (e.g., cards).\n\n.list-group-flush {\n @include border-radius(0);\n\n > .list-group-item {\n border-width: 0 0 $list-group-border-width;\n\n &:last-child {\n border-bottom-width: 0;\n }\n }\n}\n\n\n// scss-docs-start list-group-modifiers\n// List group contextual variants\n//\n// Add modifier classes to change text and background color on individual items.\n// Organizationally, this must come after the `:hover` states.\n\n@each $state, $value in $theme-colors {\n $list-group-variant-bg: shift-color($value, $list-group-item-bg-scale);\n $list-group-variant-color: shift-color($value, $list-group-item-color-scale);\n @if (contrast-ratio($list-group-variant-bg, $list-group-variant-color) < $min-contrast-ratio) {\n $list-group-variant-color: mix($value, color-contrast($list-group-variant-bg), abs($list-group-item-color-scale));\n }\n\n @include list-group-item-variant($state, $list-group-variant-bg, $list-group-variant-color);\n}\n// scss-docs-end list-group-modifiers\n","// List Groups\n\n// scss-docs-start list-group-mixin\n@mixin list-group-item-variant($state, $background, $color) {\n .list-group-item-#{$state} {\n color: $color;\n background-color: $background;\n\n &.list-group-item-action {\n &:hover,\n &:focus {\n color: $color;\n background-color: shade-color($background, 10%);\n }\n\n &.active {\n color: $white;\n background-color: $color;\n border-color: $color;\n }\n }\n }\n}\n// scss-docs-end list-group-mixin\n","// transparent background and border properties included for button version.\n// iOS requires the button element instead of an anchor tag.\n// If you want the anchor version, it requires `href=\"#\"`.\n// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n\n.btn-close {\n box-sizing: content-box;\n width: $btn-close-width;\n height: $btn-close-height;\n padding: $btn-close-padding-y $btn-close-padding-x;\n color: $btn-close-color;\n background: transparent escape-svg($btn-close-bg) center / $btn-close-width auto no-repeat; // include transparent for button elements\n border: 0; // for button elements\n @include border-radius();\n opacity: $btn-close-opacity;\n\n // Override <a>'s hover style\n &:hover {\n color: $btn-close-color;\n text-decoration: none;\n opacity: $btn-close-hover-opacity;\n }\n\n &:focus {\n outline: 0;\n box-shadow: $btn-close-focus-shadow;\n opacity: $btn-close-focus-opacity;\n }\n\n &:disabled,\n &.disabled {\n pointer-events: none;\n user-select: none;\n opacity: $btn-close-disabled-opacity;\n }\n}\n\n.btn-close-white {\n filter: $btn-close-white-filter;\n}\n",".toast {\n width: $toast-max-width;\n max-width: 100%;\n @include font-size($toast-font-size);\n color: $toast-color;\n pointer-events: auto;\n background-color: $toast-background-color;\n background-clip: padding-box;\n border: $toast-border-width solid $toast-border-color;\n box-shadow: $toast-box-shadow;\n @include border-radius($toast-border-radius);\n\n &.showing {\n opacity: 0;\n }\n\n &:not(.show) {\n display: none;\n }\n}\n\n.toast-container {\n width: max-content;\n max-width: 100%;\n pointer-events: none;\n\n > :not(:last-child) {\n margin-bottom: $toast-spacing;\n }\n}\n\n.toast-header {\n display: flex;\n align-items: center;\n padding: $toast-padding-y $toast-padding-x;\n color: $toast-header-color;\n background-color: $toast-header-background-color;\n background-clip: padding-box;\n border-bottom: $toast-border-width solid $toast-header-border-color;\n @include border-top-radius(subtract($toast-border-radius, $toast-border-width));\n\n .btn-close {\n margin-right: $toast-padding-x * -.5;\n margin-left: $toast-padding-x;\n }\n}\n\n.toast-body {\n padding: $toast-padding-x; // apply to both vertical and horizontal\n word-wrap: break-word;\n}\n","// .modal-open - body class for killing the scroll\n// .modal - container to scroll within\n// .modal-dialog - positioning shell for the actual modal\n// .modal-content - actual modal w/ bg and corners and stuff\n\n\n// Container that the modal scrolls within\n.modal {\n position: fixed;\n top: 0;\n left: 0;\n z-index: $zindex-modal;\n display: none;\n width: 100%;\n height: 100%;\n overflow-x: hidden;\n overflow-y: auto;\n // Prevent Chrome on Windows from adding a focus outline. For details, see\n // https://github.com/twbs/bootstrap/pull/10951.\n outline: 0;\n // We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a\n // gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342\n // See also https://github.com/twbs/bootstrap/issues/17695\n}\n\n// Shell div to position the modal with bottom padding\n.modal-dialog {\n position: relative;\n width: auto;\n margin: $modal-dialog-margin;\n // allow clicks to pass through for custom click handling to close modal\n pointer-events: none;\n\n // When fading in the modal, animate it to slide down\n .modal.fade & {\n @include transition($modal-transition);\n transform: $modal-fade-transform;\n }\n .modal.show & {\n transform: $modal-show-transform;\n }\n\n // When trying to close, animate focus to scale\n .modal.modal-static & {\n transform: $modal-scale-transform;\n }\n}\n\n.modal-dialog-scrollable {\n height: subtract(100%, $modal-dialog-margin * 2);\n\n .modal-content {\n max-height: 100%;\n overflow: hidden;\n }\n\n .modal-body {\n overflow-y: auto;\n }\n}\n\n.modal-dialog-centered {\n display: flex;\n align-items: center;\n min-height: subtract(100%, $modal-dialog-margin * 2);\n}\n\n// Actual modal\n.modal-content {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%; // Ensure `.modal-content` extends the full width of the parent `.modal-dialog`\n // counteract the pointer-events: none; in the .modal-dialog\n color: $modal-content-color;\n pointer-events: auto;\n background-color: $modal-content-bg;\n background-clip: padding-box;\n border: $modal-content-border-width solid $modal-content-border-color;\n @include border-radius($modal-content-border-radius);\n @include box-shadow($modal-content-box-shadow-xs);\n // Remove focus outline from opened modal\n outline: 0;\n}\n\n// Modal background\n.modal-backdrop {\n @include overlay-backdrop($zindex-modal-backdrop, $modal-backdrop-bg, $modal-backdrop-opacity);\n}\n\n// Modal header\n// Top section of the modal w/ title and dismiss\n.modal-header {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n justify-content: space-between; // Put modal header elements (title and dismiss) on opposite ends\n padding: $modal-header-padding;\n border-bottom: $modal-header-border-width solid $modal-header-border-color;\n @include border-top-radius($modal-content-inner-border-radius);\n\n .btn-close {\n padding: ($modal-header-padding-y * .5) ($modal-header-padding-x * .5);\n margin: ($modal-header-padding-y * -.5) ($modal-header-padding-x * -.5) ($modal-header-padding-y * -.5) auto;\n }\n}\n\n// Title text within header\n.modal-title {\n margin-bottom: 0;\n line-height: $modal-title-line-height;\n}\n\n// Modal body\n// Where all modal content resides (sibling of .modal-header and .modal-footer)\n.modal-body {\n position: relative;\n // Enable `flex-grow: 1` so that the body take up as much space as possible\n // when there should be a fixed height on `.modal-dialog`.\n flex: 1 1 auto;\n padding: $modal-inner-padding;\n}\n\n// Footer (for actions)\n.modal-footer {\n display: flex;\n flex-wrap: wrap;\n flex-shrink: 0;\n align-items: center; // vertically center\n justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items\n padding: $modal-inner-padding - $modal-footer-margin-between * .5;\n border-top: $modal-footer-border-width solid $modal-footer-border-color;\n @include border-bottom-radius($modal-content-inner-border-radius);\n\n // Place margin between footer elements\n // This solution is far from ideal because of the universal selector usage,\n // but is needed to fix https://github.com/twbs/bootstrap/issues/24800\n > * {\n margin: $modal-footer-margin-between * .5;\n }\n}\n\n// Scale up the modal\n@include media-breakpoint-up(sm) {\n // Automatically set modal's width for larger viewports\n .modal-dialog {\n max-width: $modal-md;\n margin: $modal-dialog-margin-y-sm-up auto;\n }\n\n .modal-dialog-scrollable {\n height: subtract(100%, $modal-dialog-margin-y-sm-up * 2);\n }\n\n .modal-dialog-centered {\n min-height: subtract(100%, $modal-dialog-margin-y-sm-up * 2);\n }\n\n .modal-content {\n @include box-shadow($modal-content-box-shadow-sm-up);\n }\n\n .modal-sm { max-width: $modal-sm; }\n}\n\n@include media-breakpoint-up(lg) {\n .modal-lg,\n .modal-xl {\n max-width: $modal-lg;\n }\n}\n\n@include media-breakpoint-up(xl) {\n .modal-xl { max-width: $modal-xl; }\n}\n\n// scss-docs-start modal-fullscreen-loop\n@each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n $postfix: if($infix != \"\", $infix + \"-down\", \"\");\n\n @include media-breakpoint-down($breakpoint) {\n .modal-fullscreen#{$postfix} {\n width: 100vw;\n max-width: none;\n height: 100%;\n margin: 0;\n\n .modal-content {\n height: 100%;\n border: 0;\n @include border-radius(0);\n }\n\n .modal-header {\n @include border-radius(0);\n }\n\n .modal-body {\n overflow-y: auto;\n }\n\n .modal-footer {\n @include border-radius(0);\n }\n }\n }\n}\n// scss-docs-end modal-fullscreen-loop\n","// Shared between modals and offcanvases\n@mixin overlay-backdrop($zindex, $backdrop-bg, $backdrop-opacity) {\n position: fixed;\n top: 0;\n left: 0;\n z-index: $zindex;\n width: 100vw;\n height: 100vh;\n background-color: $backdrop-bg;\n\n // Fade for backdrop\n &.fade { opacity: 0; }\n &.show { opacity: $backdrop-opacity; }\n}\n","// Base class\n.tooltip {\n position: absolute;\n z-index: $zindex-tooltip;\n display: block;\n margin: $tooltip-margin;\n // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n @include reset-text();\n @include font-size($tooltip-font-size);\n // Allow breaking very long words so they don't overflow the tooltip's bounds\n word-wrap: break-word;\n opacity: 0;\n\n &.show { opacity: $tooltip-opacity; }\n\n .tooltip-arrow {\n position: absolute;\n display: block;\n width: $tooltip-arrow-width;\n height: $tooltip-arrow-height;\n\n &::before {\n position: absolute;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n }\n }\n}\n\n.bs-tooltip-top {\n padding: $tooltip-arrow-height 0;\n\n .tooltip-arrow {\n bottom: 0;\n\n &::before {\n top: -1px;\n border-width: $tooltip-arrow-height ($tooltip-arrow-width * .5) 0;\n border-top-color: $tooltip-arrow-color;\n }\n }\n}\n\n.bs-tooltip-end {\n padding: 0 $tooltip-arrow-height;\n\n .tooltip-arrow {\n left: 0;\n width: $tooltip-arrow-height;\n height: $tooltip-arrow-width;\n\n &::before {\n right: -1px;\n border-width: ($tooltip-arrow-width * .5) $tooltip-arrow-height ($tooltip-arrow-width * .5) 0;\n border-right-color: $tooltip-arrow-color;\n }\n }\n}\n\n.bs-tooltip-bottom {\n padding: $tooltip-arrow-height 0;\n\n .tooltip-arrow {\n top: 0;\n\n &::before {\n bottom: -1px;\n border-width: 0 ($tooltip-arrow-width * .5) $tooltip-arrow-height;\n border-bottom-color: $tooltip-arrow-color;\n }\n }\n}\n\n.bs-tooltip-start {\n padding: 0 $tooltip-arrow-height;\n\n .tooltip-arrow {\n right: 0;\n width: $tooltip-arrow-height;\n height: $tooltip-arrow-width;\n\n &::before {\n left: -1px;\n border-width: ($tooltip-arrow-width * .5) 0 ($tooltip-arrow-width * .5) $tooltip-arrow-height;\n border-left-color: $tooltip-arrow-color;\n }\n }\n}\n\n.bs-tooltip-auto {\n &[data-popper-placement^=\"top\"] {\n @extend .bs-tooltip-top;\n }\n &[data-popper-placement^=\"right\"] {\n @extend .bs-tooltip-end;\n }\n &[data-popper-placement^=\"bottom\"] {\n @extend .bs-tooltip-bottom;\n }\n &[data-popper-placement^=\"left\"] {\n @extend .bs-tooltip-start;\n }\n}\n\n// Wrapper for the tooltip content\n.tooltip-inner {\n max-width: $tooltip-max-width;\n padding: $tooltip-padding-y $tooltip-padding-x;\n color: $tooltip-color;\n text-align: center;\n background-color: $tooltip-bg;\n @include border-radius($tooltip-border-radius);\n}\n","@mixin reset-text {\n font-family: $font-family-base;\n // We deliberately do NOT reset font-size or overflow-wrap / word-wrap.\n font-style: normal;\n font-weight: $font-weight-normal;\n line-height: $line-height-base;\n text-align: left; // Fallback for where `start` is not supported\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n}\n",".popover {\n position: absolute;\n top: 0;\n left: 0 #{\"/* rtl:ignore */\"};\n z-index: $zindex-popover;\n display: block;\n max-width: $popover-max-width;\n // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n @include reset-text();\n @include font-size($popover-font-size);\n // Allow breaking very long words so they don't overflow the popover's bounds\n word-wrap: break-word;\n background-color: $popover-bg;\n background-clip: padding-box;\n border: $popover-border-width solid $popover-border-color;\n @include border-radius($popover-border-radius);\n @include box-shadow($popover-box-shadow);\n\n .popover-arrow {\n position: absolute;\n display: block;\n width: $popover-arrow-width;\n height: $popover-arrow-height;\n\n &::before,\n &::after {\n position: absolute;\n display: block;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n }\n }\n}\n\n.bs-popover-top {\n > .popover-arrow {\n bottom: subtract(-$popover-arrow-height, $popover-border-width);\n\n &::before {\n bottom: 0;\n border-width: $popover-arrow-height ($popover-arrow-width * .5) 0;\n border-top-color: $popover-arrow-outer-color;\n }\n\n &::after {\n bottom: $popover-border-width;\n border-width: $popover-arrow-height ($popover-arrow-width * .5) 0;\n border-top-color: $popover-arrow-color;\n }\n }\n}\n\n.bs-popover-end {\n > .popover-arrow {\n left: subtract(-$popover-arrow-height, $popover-border-width);\n width: $popover-arrow-height;\n height: $popover-arrow-width;\n\n &::before {\n left: 0;\n border-width: ($popover-arrow-width * .5) $popover-arrow-height ($popover-arrow-width * .5) 0;\n border-right-color: $popover-arrow-outer-color;\n }\n\n &::after {\n left: $popover-border-width;\n border-width: ($popover-arrow-width * .5) $popover-arrow-height ($popover-arrow-width * .5) 0;\n border-right-color: $popover-arrow-color;\n }\n }\n}\n\n.bs-popover-bottom {\n > .popover-arrow {\n top: subtract(-$popover-arrow-height, $popover-border-width);\n\n &::before {\n top: 0;\n border-width: 0 ($popover-arrow-width * .5) $popover-arrow-height ($popover-arrow-width * .5);\n border-bottom-color: $popover-arrow-outer-color;\n }\n\n &::after {\n top: $popover-border-width;\n border-width: 0 ($popover-arrow-width * .5) $popover-arrow-height ($popover-arrow-width * .5);\n border-bottom-color: $popover-arrow-color;\n }\n }\n\n // This will remove the popover-header's border just below the arrow\n .popover-header::before {\n position: absolute;\n top: 0;\n left: 50%;\n display: block;\n width: $popover-arrow-width;\n margin-left: -$popover-arrow-width * .5;\n content: \"\";\n border-bottom: $popover-border-width solid $popover-header-bg;\n }\n}\n\n.bs-popover-start {\n > .popover-arrow {\n right: subtract(-$popover-arrow-height, $popover-border-width);\n width: $popover-arrow-height;\n height: $popover-arrow-width;\n\n &::before {\n right: 0;\n border-width: ($popover-arrow-width * .5) 0 ($popover-arrow-width * .5) $popover-arrow-height;\n border-left-color: $popover-arrow-outer-color;\n }\n\n &::after {\n right: $popover-border-width;\n border-width: ($popover-arrow-width * .5) 0 ($popover-arrow-width * .5) $popover-arrow-height;\n border-left-color: $popover-arrow-color;\n }\n }\n}\n\n.bs-popover-auto {\n &[data-popper-placement^=\"top\"] {\n @extend .bs-popover-top;\n }\n &[data-popper-placement^=\"right\"] {\n @extend .bs-popover-end;\n }\n &[data-popper-placement^=\"bottom\"] {\n @extend .bs-popover-bottom;\n }\n &[data-popper-placement^=\"left\"] {\n @extend .bs-popover-start;\n }\n}\n\n// Offset the popover to account for the popover arrow\n.popover-header {\n padding: $popover-header-padding-y $popover-header-padding-x;\n margin-bottom: 0; // Reset the default from Reboot\n @include font-size($font-size-base);\n color: $popover-header-color;\n background-color: $popover-header-bg;\n border-bottom: $popover-border-width solid $popover-border-color;\n @include border-top-radius($popover-inner-border-radius);\n\n &:empty {\n display: none;\n }\n}\n\n.popover-body {\n padding: $popover-body-padding-y $popover-body-padding-x;\n color: $popover-body-color;\n}\n","// Notes on the classes:\n//\n// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)\n// even when their scroll action started on a carousel, but for compatibility (with Firefox)\n// we're preventing all actions instead\n// 2. The .carousel-item-start and .carousel-item-end is used to indicate where\n// the active slide is heading.\n// 3. .active.carousel-item is the current slide.\n// 4. .active.carousel-item-start and .active.carousel-item-end is the current\n// slide in its in-transition state. Only one of these occurs at a time.\n// 5. .carousel-item-next.carousel-item-start and .carousel-item-prev.carousel-item-end\n// is the upcoming slide in transition.\n\n.carousel {\n position: relative;\n}\n\n.carousel.pointer-event {\n touch-action: pan-y;\n}\n\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n @include clearfix();\n}\n\n.carousel-item {\n position: relative;\n display: none;\n float: left;\n width: 100%;\n margin-right: -100%;\n backface-visibility: hidden;\n @include transition($carousel-transition);\n}\n\n.carousel-item.active,\n.carousel-item-next,\n.carousel-item-prev {\n display: block;\n}\n\n/* rtl:begin:ignore */\n.carousel-item-next:not(.carousel-item-start),\n.active.carousel-item-end {\n transform: translateX(100%);\n}\n\n.carousel-item-prev:not(.carousel-item-end),\n.active.carousel-item-start {\n transform: translateX(-100%);\n}\n\n/* rtl:end:ignore */\n\n\n//\n// Alternate transitions\n//\n\n.carousel-fade {\n .carousel-item {\n opacity: 0;\n transition-property: opacity;\n transform: none;\n }\n\n .carousel-item.active,\n .carousel-item-next.carousel-item-start,\n .carousel-item-prev.carousel-item-end {\n z-index: 1;\n opacity: 1;\n }\n\n .active.carousel-item-start,\n .active.carousel-item-end {\n z-index: 0;\n opacity: 0;\n @include transition(opacity 0s $carousel-transition-duration);\n }\n}\n\n\n//\n// Left/right controls for nav\n//\n\n.carousel-control-prev,\n.carousel-control-next {\n position: absolute;\n top: 0;\n bottom: 0;\n z-index: 1;\n // Use flex for alignment (1-3)\n display: flex; // 1. allow flex styles\n align-items: center; // 2. vertically center contents\n justify-content: center; // 3. horizontally center contents\n width: $carousel-control-width;\n padding: 0;\n color: $carousel-control-color;\n text-align: center;\n background: none;\n border: 0;\n opacity: $carousel-control-opacity;\n @include transition($carousel-control-transition);\n\n // Hover/focus state\n &:hover,\n &:focus {\n color: $carousel-control-color;\n text-decoration: none;\n outline: 0;\n opacity: $carousel-control-hover-opacity;\n }\n}\n.carousel-control-prev {\n left: 0;\n background-image: if($enable-gradients, linear-gradient(90deg, rgba($black, .25), rgba($black, .001)), null);\n}\n.carousel-control-next {\n right: 0;\n background-image: if($enable-gradients, linear-gradient(270deg, rgba($black, .25), rgba($black, .001)), null);\n}\n\n// Icons for within\n.carousel-control-prev-icon,\n.carousel-control-next-icon {\n display: inline-block;\n width: $carousel-control-icon-width;\n height: $carousel-control-icon-width;\n background-repeat: no-repeat;\n background-position: 50%;\n background-size: 100% 100%;\n}\n\n/* rtl:options: {\n \"autoRename\": true,\n \"stringMap\":[ {\n \"name\" : \"prev-next\",\n \"search\" : \"prev\",\n \"replace\" : \"next\"\n } ]\n} */\n.carousel-control-prev-icon {\n background-image: escape-svg($carousel-control-prev-icon-bg);\n}\n.carousel-control-next-icon {\n background-image: escape-svg($carousel-control-next-icon-bg);\n}\n\n// Optional indicator pips/controls\n//\n// Add a container (such as a list) with the following class and add an item (ideally a focusable control,\n// like a button) with data-bs-target for each slide your carousel holds.\n\n.carousel-indicators {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 2;\n display: flex;\n justify-content: center;\n padding: 0;\n // Use the .carousel-control's width as margin so we don't overlay those\n margin-right: $carousel-control-width;\n margin-bottom: 1rem;\n margin-left: $carousel-control-width;\n list-style: none;\n\n [data-bs-target] {\n box-sizing: content-box;\n flex: 0 1 auto;\n width: $carousel-indicator-width;\n height: $carousel-indicator-height;\n padding: 0;\n margin-right: $carousel-indicator-spacer;\n margin-left: $carousel-indicator-spacer;\n text-indent: -999px;\n cursor: pointer;\n background-color: $carousel-indicator-active-bg;\n background-clip: padding-box;\n border: 0;\n // Use transparent borders to increase the hit area by 10px on top and bottom.\n border-top: $carousel-indicator-hit-area-height solid transparent;\n border-bottom: $carousel-indicator-hit-area-height solid transparent;\n opacity: $carousel-indicator-opacity;\n @include transition($carousel-indicator-transition);\n }\n\n .active {\n opacity: $carousel-indicator-active-opacity;\n }\n}\n\n\n// Optional captions\n//\n//\n\n.carousel-caption {\n position: absolute;\n right: (100% - $carousel-caption-width) * .5;\n bottom: $carousel-caption-spacer;\n left: (100% - $carousel-caption-width) * .5;\n padding-top: $carousel-caption-padding-y;\n padding-bottom: $carousel-caption-padding-y;\n color: $carousel-caption-color;\n text-align: center;\n}\n\n// Dark mode carousel\n\n.carousel-dark {\n .carousel-control-prev-icon,\n .carousel-control-next-icon {\n filter: $carousel-dark-control-icon-filter;\n }\n\n .carousel-indicators [data-bs-target] {\n background-color: $carousel-dark-indicator-active-bg;\n }\n\n .carousel-caption {\n color: $carousel-dark-caption-color;\n }\n}\n","// scss-docs-start clearfix\n@mixin clearfix() {\n &::after {\n display: block;\n clear: both;\n content: \"\";\n }\n}\n// scss-docs-end clearfix\n","//\n// Rotating border\n//\n\n// scss-docs-start spinner-border-keyframes\n@keyframes spinner-border {\n to { transform: rotate(360deg) #{\"/* rtl:ignore */\"}; }\n}\n// scss-docs-end spinner-border-keyframes\n\n.spinner-border {\n display: inline-block;\n width: $spinner-width;\n height: $spinner-height;\n vertical-align: $spinner-vertical-align;\n border: $spinner-border-width solid currentColor;\n border-right-color: transparent;\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 50%;\n animation: $spinner-animation-speed linear infinite spinner-border;\n}\n\n.spinner-border-sm {\n width: $spinner-width-sm;\n height: $spinner-height-sm;\n border-width: $spinner-border-width-sm;\n}\n\n//\n// Growing circle\n//\n\n// scss-docs-start spinner-grow-keyframes\n@keyframes spinner-grow {\n 0% {\n transform: scale(0);\n }\n 50% {\n opacity: 1;\n transform: none;\n }\n}\n// scss-docs-end spinner-grow-keyframes\n\n.spinner-grow {\n display: inline-block;\n width: $spinner-width;\n height: $spinner-height;\n vertical-align: $spinner-vertical-align;\n background-color: currentColor;\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 50%;\n opacity: 0;\n animation: $spinner-animation-speed linear infinite spinner-grow;\n}\n\n.spinner-grow-sm {\n width: $spinner-width-sm;\n height: $spinner-height-sm;\n}\n\n@if $enable-reduced-motion {\n @media (prefers-reduced-motion: reduce) {\n .spinner-border,\n .spinner-grow {\n animation-duration: $spinner-animation-speed * 2;\n }\n }\n}\n",".offcanvas {\n position: fixed;\n bottom: 0;\n z-index: $zindex-offcanvas;\n display: flex;\n flex-direction: column;\n max-width: 100%;\n color: $offcanvas-color;\n visibility: hidden;\n background-color: $offcanvas-bg-color;\n background-clip: padding-box;\n outline: 0;\n @include box-shadow($offcanvas-box-shadow);\n @include transition(transform $offcanvas-transition-duration ease-in-out);\n}\n\n.offcanvas-backdrop {\n @include overlay-backdrop($zindex-offcanvas-backdrop, $offcanvas-backdrop-bg, $offcanvas-backdrop-opacity);\n}\n\n.offcanvas-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: $offcanvas-padding-y $offcanvas-padding-x;\n\n .btn-close {\n padding: ($offcanvas-padding-y * .5) ($offcanvas-padding-x * .5);\n margin-top: $offcanvas-padding-y * -.5;\n margin-right: $offcanvas-padding-x * -.5;\n margin-bottom: $offcanvas-padding-y * -.5;\n }\n}\n\n.offcanvas-title {\n margin-bottom: 0;\n line-height: $offcanvas-title-line-height;\n}\n\n.offcanvas-body {\n flex-grow: 1;\n padding: $offcanvas-padding-y $offcanvas-padding-x;\n overflow-y: auto;\n}\n\n.offcanvas-start {\n top: 0;\n left: 0;\n width: $offcanvas-horizontal-width;\n border-right: $offcanvas-border-width solid $offcanvas-border-color;\n transform: translateX(-100%);\n}\n\n.offcanvas-end {\n top: 0;\n right: 0;\n width: $offcanvas-horizontal-width;\n border-left: $offcanvas-border-width solid $offcanvas-border-color;\n transform: translateX(100%);\n}\n\n.offcanvas-top {\n top: 0;\n right: 0;\n left: 0;\n height: $offcanvas-vertical-height;\n max-height: 100%;\n border-bottom: $offcanvas-border-width solid $offcanvas-border-color;\n transform: translateY(-100%);\n}\n\n.offcanvas-bottom {\n right: 0;\n left: 0;\n height: $offcanvas-vertical-height;\n max-height: 100%;\n border-top: $offcanvas-border-width solid $offcanvas-border-color;\n transform: translateY(100%);\n}\n\n.offcanvas.show {\n transform: none;\n}\n",".placeholder {\n display: inline-block;\n min-height: 1em;\n vertical-align: middle;\n cursor: wait;\n background-color: currentColor;\n opacity: $placeholder-opacity-max;\n\n &.btn::before {\n display: inline-block;\n content: \"\";\n }\n}\n\n// Sizing\n.placeholder-xs {\n min-height: .6em;\n}\n\n.placeholder-sm {\n min-height: .8em;\n}\n\n.placeholder-lg {\n min-height: 1.2em;\n}\n\n// Animation\n.placeholder-glow {\n .placeholder {\n animation: placeholder-glow 2s ease-in-out infinite;\n }\n}\n\n@keyframes placeholder-glow {\n 50% {\n opacity: $placeholder-opacity-min;\n }\n}\n\n.placeholder-wave {\n mask-image: linear-gradient(130deg, $black 55%, rgba(0, 0, 0, (1 - $placeholder-opacity-min)) 75%, $black 95%);\n mask-size: 200% 100%;\n animation: placeholder-wave 2s linear infinite;\n}\n\n@keyframes placeholder-wave {\n 100% {\n mask-position: -200% 0%;\n }\n}\n","@each $color, $value in $theme-colors {\n .link-#{$color} {\n color: $value;\n\n @if $link-shade-percentage != 0 {\n &:hover,\n &:focus {\n color: if(color-contrast($value) == $color-contrast-light, shade-color($value, $link-shade-percentage), tint-color($value, $link-shade-percentage));\n }\n }\n }\n}\n","// Credit: Nicolas Gallagher and SUIT CSS.\n\n.ratio {\n position: relative;\n width: 100%;\n\n &::before {\n display: block;\n padding-top: var(--#{$variable-prefix}aspect-ratio);\n content: \"\";\n }\n\n > * {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n }\n}\n\n@each $key, $ratio in $aspect-ratios {\n .ratio-#{$key} {\n --#{$variable-prefix}aspect-ratio: #{$ratio};\n }\n}\n","// Shorthand\n\n.fixed-top {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: $zindex-fixed;\n}\n\n.fixed-bottom {\n position: fixed;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: $zindex-fixed;\n}\n\n// Responsive sticky top\n@each $breakpoint in map-keys($grid-breakpoints) {\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n .sticky#{$infix}-top {\n position: sticky;\n top: 0;\n z-index: $zindex-sticky;\n }\n }\n}\n","// scss-docs-start stacks\n.hstack {\n display: flex;\n flex-direction: row;\n align-items: center;\n align-self: stretch;\n}\n\n.vstack {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-self: stretch;\n}\n// scss-docs-end stacks\n","//\n// Visually hidden\n//\n\n.visually-hidden,\n.visually-hidden-focusable:not(:focus):not(:focus-within) {\n @include visually-hidden();\n}\n","// stylelint-disable declaration-no-important\n\n// Hide content visually while keeping it accessible to assistive technologies\n//\n// See: https://www.a11yproject.com/posts/2013-01-11-how-to-hide-content/\n// See: https://hugogiraudel.com/2016/10/13/css-hide-and-seek/\n\n@mixin visually-hidden() {\n position: absolute !important;\n width: 1px !important;\n height: 1px !important;\n padding: 0 !important;\n margin: -1px !important; // Fix for https://github.com/twbs/bootstrap/issues/25686\n overflow: hidden !important;\n clip: rect(0, 0, 0, 0) !important;\n white-space: nowrap !important;\n border: 0 !important;\n}\n\n// Use to only display content when it's focused, or one of its child elements is focused\n// (i.e. when focus is within the element/container that the class was applied to)\n//\n// Useful for \"Skip to main content\" links; see https://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1\n\n@mixin visually-hidden-focusable() {\n &:not(:focus):not(:focus-within) {\n @include visually-hidden();\n }\n}\n","//\n// Stretched link\n//\n\n.stretched-link {\n &::#{$stretched-link-pseudo-element} {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: $stretched-link-z-index;\n content: \"\";\n }\n}\n","//\n// Text truncation\n//\n\n.text-truncate {\n @include text-truncate();\n}\n","// Text truncate\n// Requires inline-block or block for proper styling\n\n@mixin text-truncate() {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n",".vr {\n display: inline-block;\n align-self: stretch;\n width: 1px;\n min-height: 1em;\n background-color: currentColor;\n opacity: $hr-opacity;\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix, $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (eg. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$variable-prefix}#{$property-class}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$variable-prefix}#{$property-class}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $value in $is-local-vars {\n --#{$variable-prefix}#{$local-var}: #{$value};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/TaskTrackingSystem.WebApp/wwwroot/favicon.png b/TaskTrackingSystem.WebApp/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 Binary files /dev/null and b/TaskTrackingSystem.WebApp/wwwroot/favicon.png differ diff --git a/TaskTrackingSystem.WebApp/wwwroot/firebase-app-compat.js b/TaskTrackingSystem.WebApp/wwwroot/firebase-app-compat.js new file mode 100644 index 0000000000000000000000000000000000000000..c6a94b02c768e65f1a92b28f476a4f93b0c12bc4 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/firebase-app-compat.js @@ -0,0 +1,8 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).firebase=t()}(this,function(){"use strict";const r=function(t){const r=[];let n=0;for(let i=0;i<t.length;i++){let e=t.charCodeAt(i);e<128?r[n++]=e:(e<2048?r[n++]=e>>6|192:(55296==(64512&e)&&i+1<t.length&&56320==(64512&t.charCodeAt(i+1))?(e=65536+((1023&e)<<10)+(1023&t.charCodeAt(++i)),r[n++]=e>>18|240,r[n++]=e>>12&63|128):r[n++]=e>>12|224,r[n++]=e>>6&63|128),r[n++]=63&e|128)}return r},n={byteToCharMap_:null,charToByteMap_:null,byteToCharMapWebSafe_:null,charToByteMapWebSafe_:null,ENCODED_VALS_BASE:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",get ENCODED_VALS(){return this.ENCODED_VALS_BASE+"+/="},get ENCODED_VALS_WEBSAFE(){return this.ENCODED_VALS_BASE+"-_."},HAS_NATIVE_SUPPORT:"function"==typeof atob,encodeByteArray(r,e){if(!Array.isArray(r))throw Error("encodeByteArray takes an array as a parameter");this.init_();var n=e?this.byteToCharMapWebSafe_:this.byteToCharMap_;const i=[];for(let h=0;h<r.length;h+=3){var a=r[h],s=h+1<r.length,o=s?r[h+1]:0,c=h+2<r.length,l=c?r[h+2]:0;let e=(15&o)<<2|l>>6,t=63&l;c||(t=64,s||(e=64)),i.push(n[a>>2],n[(3&a)<<4|o>>4],n[e],n[t])}return i.join("")},encodeString(e,t){return this.HAS_NATIVE_SUPPORT&&!t?btoa(e):this.encodeByteArray(r(e),t)},decodeString(e,t){return this.HAS_NATIVE_SUPPORT&&!t?atob(e):function(e){const t=[];let r=0,n=0;for(;r<e.length;){var i,a,s=e[r++];s<128?t[n++]=String.fromCharCode(s):191<s&&s<224?(i=e[r++],t[n++]=String.fromCharCode((31&s)<<6|63&i)):239<s&&s<365?(a=((7&s)<<18|(63&e[r++])<<12|(63&e[r++])<<6|63&e[r++])-65536,t[n++]=String.fromCharCode(55296+(a>>10)),t[n++]=String.fromCharCode(56320+(1023&a))):(i=e[r++],a=e[r++],t[n++]=String.fromCharCode((15&s)<<12|(63&i)<<6|63&a))}return t.join("")}(this.decodeStringToByteArray(e,t))},decodeStringToByteArray(e,t){this.init_();var r=t?this.charToByteMapWebSafe_:this.charToByteMap_;const n=[];for(let c=0;c<e.length;){var i=r[e.charAt(c++)],a=c<e.length?r[e.charAt(c)]:0;++c;var s=c<e.length?r[e.charAt(c)]:64;++c;var o=c<e.length?r[e.charAt(c)]:64;if(++c,null==i||null==a||null==s||null==o)throw new l;n.push(i<<2|a>>4),64!==s&&(n.push(a<<4&240|s>>2),64!==o&&n.push(s<<6&192|o))}return n},init_(){if(!this.byteToCharMap_){this.byteToCharMap_={},this.charToByteMap_={},this.byteToCharMapWebSafe_={},this.charToByteMapWebSafe_={};for(let e=0;e<this.ENCODED_VALS.length;e++)this.byteToCharMap_[e]=this.ENCODED_VALS.charAt(e),this.charToByteMap_[this.byteToCharMap_[e]]=e,this.byteToCharMapWebSafe_[e]=this.ENCODED_VALS_WEBSAFE.charAt(e),this.charToByteMapWebSafe_[this.byteToCharMapWebSafe_[e]]=e,e>=this.ENCODED_VALS_BASE.length&&(this.charToByteMap_[this.ENCODED_VALS_WEBSAFE.charAt(e)]=e,this.charToByteMapWebSafe_[this.ENCODED_VALS.charAt(e)]=e)}}};class l extends Error{constructor(){super(...arguments),this.name="DecodeBase64StringError"}}const i=function(e){return e=e,t=r(e),n.encodeByteArray(t,!0).replace(/\./g,"");var t};function c(e,t){if(!(t instanceof Object))return t;switch(t.constructor){case Date:const r=t;return new Date(r.getTime());case Object:void 0===e&&(e={});break;case Array:e=[];break;default:return t}for(const n in t)t.hasOwnProperty(n)&&"__proto__"!==n&&(e[n]=c(e[n],t[n]));return e}const e=()=>function(){if("undefined"!=typeof self)return self;if("undefined"!=typeof window)return window;if("undefined"!=typeof global)return global;throw new Error("Unable to locate global object.")}().__FIREBASE_DEFAULTS__,t=()=>{if("undefined"!=typeof document){let e;try{e=document.cookie.match(/__FIREBASE_DEFAULTS__=([^;]+)/)}catch(e){return}var t=e&&function(e){try{return n.decodeString(e,!0)}catch(e){console.error("base64Decode failed: ",e)}return null}(e[1]);return t&&JSON.parse(t)}},a=()=>{try{return e()||(()=>{if("undefined"!=typeof process&&void 0!==process.env){var e=process.env.__FIREBASE_DEFAULTS__;return e?JSON.parse(e):void 0}})()||t()}catch(e){return void console.info(`Unable to get __FIREBASE_DEFAULTS__ due to: ${e}`)}},h=()=>{var e;return null===(e=a())||void 0===e?void 0:e.config};class s{constructor(){this.reject=()=>{},this.resolve=()=>{},this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}wrapCallback(r){return(e,t)=>{e?this.reject(e):this.resolve(t),"function"==typeof r&&(this.promise.catch(()=>{}),1===r.length?r(e):r(e,t))}}}function d(){return"undefined"!=typeof window||u()}function u(){return"undefined"!=typeof WorkerGlobalScope&&"undefined"!=typeof self&&self instanceof WorkerGlobalScope}class o extends Error{constructor(e,t,r){super(t),this.code=e,this.customData=r,this.name="FirebaseError",Object.setPrototypeOf(this,o.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,p.prototype.create)}}class p{constructor(e,t,r){this.service=e,this.serviceName=t,this.errors=r}create(e,...t){var n,r=t[0]||{},i=`${this.service}/${e}`,a=this.errors[e],a=a?(n=r,a.replace(f,(e,t)=>{var r=n[t];return null!=r?String(r):`<${t}?>`})):"Error",a=`${this.serviceName}: ${a} (${i}).`;return new o(i,a,r)}}const f=/\{\$([^}]+)}/g;function g(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function b(e,t){if(e===t)return 1;const r=Object.keys(e),n=Object.keys(t);for(const s of r){if(!n.includes(s))return;var i=e[s],a=t[s];if(m(i)&&m(a)){if(!b(i,a))return}else if(i!==a)return}for(const o of n)if(!r.includes(o))return;return 1}function m(e){return null!==e&&"object"==typeof e}function v(e,t){const r=new _(e,t);return r.subscribe.bind(r)}class _{constructor(e,t){this.observers=[],this.unsubscribes=[],this.observerCount=0,this.task=Promise.resolve(),this.finalized=!1,this.onNoObservers=t,this.task.then(()=>{e(this)}).catch(e=>{this.error(e)})}next(t){this.forEachObserver(e=>{e.next(t)})}error(t){this.forEachObserver(e=>{e.error(t)}),this.close(t)}complete(){this.forEachObserver(e=>{e.complete()}),this.close()}subscribe(e,t,r){let n;if(void 0===e&&void 0===t&&void 0===r)throw new Error("Missing Observer.");n=function(e,t){if("object"!=typeof e||null===e)return!1;for(const r of t)if(r in e&&"function"==typeof e[r])return!0;return!1}(e,["next","error","complete"])?e:{next:e,error:t,complete:r},void 0===n.next&&(n.next=y),void 0===n.error&&(n.error=y),void 0===n.complete&&(n.complete=y);var i=this.unsubscribeOne.bind(this,this.observers.length);return this.finalized&&this.task.then(()=>{try{this.finalError?n.error(this.finalError):n.complete()}catch(e){}}),this.observers.push(n),i}unsubscribeOne(e){void 0!==this.observers&&void 0!==this.observers[e]&&(delete this.observers[e],--this.observerCount,0===this.observerCount&&void 0!==this.onNoObservers&&this.onNoObservers(this))}forEachObserver(t){if(!this.finalized)for(let e=0;e<this.observers.length;e++)this.sendOne(e,t)}sendOne(e,t){this.task.then(()=>{if(void 0!==this.observers&&void 0!==this.observers[e])try{t(this.observers[e])}catch(e){"undefined"!=typeof console&&console.error&&console.error(e)}})}close(e){this.finalized||(this.finalized=!0,void 0!==e&&(this.finalError=e),this.task.then(()=>{this.observers=void 0,this.onNoObservers=void 0}))}}function y(){}class E{constructor(e,t,r){this.name=e,this.instanceFactory=t,this.type=r,this.multipleInstances=!1,this.serviceProps={},this.instantiationMode="LAZY",this.onInstanceCreated=null}setInstantiationMode(e){return this.instantiationMode=e,this}setMultipleInstances(e){return this.multipleInstances=e,this}setServiceProps(e){return this.serviceProps=e,this}setInstanceCreatedCallback(e){return this.onInstanceCreated=e,this}}const w="[DEFAULT]";class C{constructor(e,t){this.name=e,this.container=t,this.component=null,this.instances=new Map,this.instancesDeferred=new Map,this.instancesOptions=new Map,this.onInitCallbacks=new Map}get(e){var t=this.normalizeInstanceIdentifier(e);if(!this.instancesDeferred.has(t)){const n=new s;if(this.instancesDeferred.set(t,n),this.isInitialized(t)||this.shouldAutoInitialize())try{var r=this.getOrInitializeService({instanceIdentifier:t});r&&n.resolve(r)}catch(e){}}return this.instancesDeferred.get(t).promise}getImmediate(e){var t=this.normalizeInstanceIdentifier(null==e?void 0:e.identifier),r=null!==(r=null==e?void 0:e.optional)&&void 0!==r&&r;if(!this.isInitialized(t)&&!this.shouldAutoInitialize()){if(r)return null;throw Error(`Service ${this.name} is not available`)}try{return this.getOrInitializeService({instanceIdentifier:t})}catch(e){if(r)return null;throw e}}getComponent(){return this.component}setComponent(e){if(e.name!==this.name)throw Error(`Mismatching Component ${e.name} for Provider ${this.name}.`);if(this.component)throw Error(`Component for ${this.name} has already been provided`);if(this.component=e,this.shouldAutoInitialize()){if("EAGER"===e.instantiationMode)try{this.getOrInitializeService({instanceIdentifier:w})}catch(e){}for(var[t,r]of this.instancesDeferred.entries()){t=this.normalizeInstanceIdentifier(t);try{var n=this.getOrInitializeService({instanceIdentifier:t});r.resolve(n)}catch(e){}}}}clearInstance(e=w){this.instancesDeferred.delete(e),this.instancesOptions.delete(e),this.instances.delete(e)}async delete(){const e=Array.from(this.instances.values());await Promise.all([...e.filter(e=>"INTERNAL"in e).map(e=>e.INTERNAL.delete()),...e.filter(e=>"_delete"in e).map(e=>e._delete())])}isComponentSet(){return null!=this.component}isInitialized(e=w){return this.instances.has(e)}getOptions(e=w){return this.instancesOptions.get(e)||{}}initialize(e={}){var{options:t={}}=e,r=this.normalizeInstanceIdentifier(e.instanceIdentifier);if(this.isInitialized(r))throw Error(`${this.name}(${r}) has already been initialized`);if(!this.isComponentSet())throw Error(`Component ${this.name} has not been registered yet`);var n,i,a=this.getOrInitializeService({instanceIdentifier:r,options:t});for([n,i]of this.instancesDeferred.entries())r===this.normalizeInstanceIdentifier(n)&&i.resolve(a);return a}onInit(e,t){var r=this.normalizeInstanceIdentifier(t);const n=null!==(i=this.onInitCallbacks.get(r))&&void 0!==i?i:new Set;n.add(e),this.onInitCallbacks.set(r,n);var i=this.instances.get(r);return i&&e(i,r),()=>{n.delete(e)}}invokeOnInitCallbacks(e,t){var r=this.onInitCallbacks.get(t);if(r)for(const n of r)try{n(e,t)}catch(e){}}getOrInitializeService({instanceIdentifier:e,options:t={}}){let r=this.instances.get(e);if(!r&&this.component&&(r=this.component.instanceFactory(this.container,{instanceIdentifier:(n=e)===w?void 0:n,options:t}),this.instances.set(e,r),this.instancesOptions.set(e,t),this.invokeOnInitCallbacks(r,e),this.component.onInstanceCreated))try{this.component.onInstanceCreated(this.container,e,r)}catch(e){}var n;return r||null}normalizeInstanceIdentifier(e=w){return!this.component||this.component.multipleInstances?e:w}shouldAutoInitialize(){return!!this.component&&"EXPLICIT"!==this.component.instantiationMode}}class D{constructor(e){this.name=e,this.providers=new Map}addComponent(e){const t=this.getProvider(e.name);if(t.isComponentSet())throw new Error(`Component ${e.name} has already been registered with ${this.name}`);t.setComponent(e)}addOrOverwriteComponent(e){const t=this.getProvider(e.name);t.isComponentSet()&&this.providers.delete(e.name),this.addComponent(e)}getProvider(e){if(this.providers.has(e))return this.providers.get(e);var t=new C(e,this);return this.providers.set(e,t),t}getProviders(){return Array.from(this.providers.values())}}const I=[];var S,O,A;(O=S=S||{})[O.DEBUG=0]="DEBUG",O[O.VERBOSE=1]="VERBOSE",O[O.INFO=2]="INFO",O[O.WARN=3]="WARN",O[O.ERROR=4]="ERROR",O[O.SILENT=5]="SILENT";const L={debug:S.DEBUG,verbose:S.VERBOSE,info:S.INFO,warn:S.WARN,error:S.ERROR,silent:S.SILENT},N=S.INFO,B={[S.DEBUG]:"log",[S.VERBOSE]:"log",[S.INFO]:"info",[S.WARN]:"warn",[S.ERROR]:"error"},T=(e,t,...r)=>{if(!(t<e.logLevel)){var n=(new Date).toISOString(),i=B[t];if(!i)throw new Error(`Attempted to log a message with an invalid logType (value: ${t})`);console[i](`[${n}] ${e.name}:`,...r)}};class R{constructor(e){this.name=e,this._logLevel=N,this._logHandler=T,this._userLogHandler=null,I.push(this)}get logLevel(){return this._logLevel}set logLevel(e){if(!(e in S))throw new TypeError(`Invalid value "${e}" assigned to \`logLevel\``);this._logLevel=e}setLogLevel(e){this._logLevel="string"==typeof e?L[e]:e}get logHandler(){return this._logHandler}set logHandler(e){if("function"!=typeof e)throw new TypeError("Value assigned to `logHandler` must be a function");this._logHandler=e}get userLogHandler(){return this._userLogHandler}set userLogHandler(e){this._userLogHandler=e}debug(...e){this._userLogHandler&&this._userLogHandler(this,S.DEBUG,...e),this._logHandler(this,S.DEBUG,...e)}log(...e){this._userLogHandler&&this._userLogHandler(this,S.VERBOSE,...e),this._logHandler(this,S.VERBOSE,...e)}info(...e){this._userLogHandler&&this._userLogHandler(this,S.INFO,...e),this._logHandler(this,S.INFO,...e)}warn(...e){this._userLogHandler&&this._userLogHandler(this,S.WARN,...e),this._logHandler(this,S.WARN,...e)}error(...e){this._userLogHandler&&this._userLogHandler(this,S.ERROR,...e),this._logHandler(this,S.ERROR,...e)}}const P=(t,e)=>e.some(e=>t instanceof e);let k,M;const F=new WeakMap,j=new WeakMap,z=new WeakMap,$=new WeakMap,H=new WeakMap;let x={get(e,t,r){if(e instanceof IDBTransaction){if("done"===t)return j.get(e);if("objectStoreNames"===t)return e.objectStoreNames||z.get(e);if("store"===t)return r.objectStoreNames[1]?void 0:r.objectStore(r.objectStoreNames[0])}return U(e[t])},set(e,t,r){return e[t]=r,!0},has(e,t){return e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e}};function V(n){return n!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(M=M||[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey]).includes(n)?function(...e){return n.apply(G(this),e),U(F.get(this))}:function(...e){return U(n.apply(G(this),e))}:function(e,...t){var r=n.call(G(this),e,...t);return z.set(r,e.sort?e.sort():[e]),U(r)}}function W(e){return"function"==typeof e?V(e):(e instanceof IDBTransaction&&(a=e,j.has(a)||(t=new Promise((e,t)=>{const r=()=>{a.removeEventListener("complete",n),a.removeEventListener("error",i),a.removeEventListener("abort",i)},n=()=>{e(),r()},i=()=>{t(a.error||new DOMException("AbortError","AbortError")),r()};a.addEventListener("complete",n),a.addEventListener("error",i),a.addEventListener("abort",i)}),j.set(a,t))),P(e,k=k||[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])?new Proxy(e,x):e);var a,t}function U(e){if(e instanceof IDBRequest)return function(a){const e=new Promise((e,t)=>{const r=()=>{a.removeEventListener("success",n),a.removeEventListener("error",i)},n=()=>{e(U(a.result)),r()},i=()=>{t(a.error),r()};a.addEventListener("success",n),a.addEventListener("error",i)});return e.then(e=>{e instanceof IDBCursor&&F.set(e,a)}).catch(()=>{}),H.set(e,a),e}(e);if($.has(e))return $.get(e);var t=W(e);return t!==e&&($.set(e,t),H.set(t,e)),t}const G=e=>H.get(e);const J=["get","getKey","getAll","getAllKeys","count"],K=["put","add","delete","clear"],Y=new Map;function X(e,t){if(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t){if(Y.get(t))return Y.get(t);const i=t.replace(/FromIndex$/,""),a=t!==i,s=K.includes(i);if(i in(a?IDBIndex:IDBObjectStore).prototype&&(s||J.includes(i))){var r=async function(e,...t){var r=this.transaction(e,s?"readwrite":"readonly");let n=r.store;return a&&(n=n.index(t.shift())),(await Promise.all([n[i](...t),s&&r.done]))[0]};return Y.set(t,r),r}}}x={...A=x,get:(e,t,r)=>X(e,t)||A.get(e,t,r),has:(e,t)=>!!X(e,t)||A.has(e,t)};class q{constructor(e){this.container=e}getPlatformInfoString(){const e=this.container.getProviders();return e.map(e=>{if("VERSION"!==(null==(t=e.getComponent())?void 0:t.type))return null;var t,t=e.getImmediate();return`${t.library}/${t.version}`}).filter(e=>e).join(" ")}}const Z="@firebase/app",Q="0.10.8",ee=new R("@firebase/app");var te;const re="[DEFAULT]",ne={"@firebase/app":"fire-core","@firebase/app-compat":"fire-core-compat","@firebase/analytics":"fire-analytics","@firebase/analytics-compat":"fire-analytics-compat","@firebase/app-check":"fire-app-check","@firebase/app-check-compat":"fire-app-check-compat","@firebase/auth":"fire-auth","@firebase/auth-compat":"fire-auth-compat","@firebase/database":"fire-rtdb","@firebase/database-compat":"fire-rtdb-compat","@firebase/functions":"fire-fn","@firebase/functions-compat":"fire-fn-compat","@firebase/installations":"fire-iid","@firebase/installations-compat":"fire-iid-compat","@firebase/messaging":"fire-fcm","@firebase/messaging-compat":"fire-fcm-compat","@firebase/performance":"fire-perf","@firebase/performance-compat":"fire-perf-compat","@firebase/remote-config":"fire-rc","@firebase/remote-config-compat":"fire-rc-compat","@firebase/storage":"fire-gcs","@firebase/storage-compat":"fire-gcs-compat","@firebase/firestore":"fire-fst","@firebase/firestore-compat":"fire-fst-compat","@firebase/vertexai-preview":"fire-vertex","fire-js":"fire-js",firebase:"fire-js-all"},ie=new Map,ae=new Map,se=new Map;function oe(t,r){try{t.container.addComponent(r)}catch(e){ee.debug(`Component ${r.name} failed to register with FirebaseApp ${t.name}`,e)}}function ce(e,t){e.container.addOrOverwriteComponent(t)}function le(e){var t=e.name;if(se.has(t))return ee.debug(`There were multiple attempts to register component ${t}.`),!1;se.set(t,e);for(const r of ie.values())oe(r,e);for(const n of ae.values())oe(n,e);return!0}function he(e,t){const r=e.container.getProvider("heartbeat").getImmediate({optional:!0});return r&&r.triggerHeartbeat(),e.container.getProvider(t)}function de(e){return void 0!==e.options}const ue=new p("app","Firebase",{"no-app":"No Firebase App '{$appName}' has been created - call initializeApp() first","bad-app-name":"Illegal App name: '{$appName}'","duplicate-app":"Firebase App named '{$appName}' already exists with different options or config","app-deleted":"Firebase App named '{$appName}' already deleted","server-app-deleted":"Firebase Server App has been deleted","no-options":"Need to provide options, when not being deployed to hosting via source.","invalid-app-argument":"firebase.{$appName}() takes either no argument or a Firebase App instance.","invalid-log-argument":"First argument to `onLog` must be null or a function.","idb-open":"Error thrown when opening IndexedDB. Original error: {$originalErrorMessage}.","idb-get":"Error thrown when reading from IndexedDB. Original error: {$originalErrorMessage}.","idb-set":"Error thrown when writing to IndexedDB. Original error: {$originalErrorMessage}.","idb-delete":"Error thrown when deleting from IndexedDB. Original error: {$originalErrorMessage}.","finalization-registry-not-supported":"FirebaseServerApp deleteOnDeref field defined but the JS runtime does not support FinalizationRegistry.","invalid-server-app-environment":"FirebaseServerApp is not for use in browser environments."});class pe{constructor(e,t,r){this._isDeleted=!1,this._options=Object.assign({},e),this._config=Object.assign({},t),this._name=t.name,this._automaticDataCollectionEnabled=t.automaticDataCollectionEnabled,this._container=r,this.container.addComponent(new E("app",()=>this,"PUBLIC"))}get automaticDataCollectionEnabled(){return this.checkDestroyed(),this._automaticDataCollectionEnabled}set automaticDataCollectionEnabled(e){this.checkDestroyed(),this._automaticDataCollectionEnabled=e}get name(){return this.checkDestroyed(),this._name}get options(){return this.checkDestroyed(),this._options}get config(){return this.checkDestroyed(),this._config}get container(){return this._container}get isDeleted(){return this._isDeleted}set isDeleted(e){this._isDeleted=e}checkDestroyed(){if(this.isDeleted)throw ue.create("app-deleted",{appName:this._name})}}class fe extends pe{constructor(e,t,r,n){var i=void 0!==t.automaticDataCollectionEnabled&&t.automaticDataCollectionEnabled,a={name:r,automaticDataCollectionEnabled:i};void 0!==e.apiKey?super(e,a,n):super(e.options,a,n),this._serverConfig=Object.assign({automaticDataCollectionEnabled:i},t),this._finalizationRegistry=null,"undefined"!=typeof FinalizationRegistry&&(this._finalizationRegistry=new FinalizationRegistry(()=>{this.automaticCleanup()})),this._refCount=0,this.incRefCount(this._serverConfig.releaseOnDeref),this._serverConfig.releaseOnDeref=void 0,t.releaseOnDeref=void 0,ve(Z,Q,"serverapp")}toJSON(){}get refCount(){return this._refCount}incRefCount(e){this.isDeleted||(this._refCount++,void 0!==e&&null!==this._finalizationRegistry&&this._finalizationRegistry.register(e,this))}decRefCount(){return this.isDeleted?0:--this._refCount}automaticCleanup(){me(this)}get settings(){return this.checkDestroyed(),this._serverConfig}checkDestroyed(){if(this.isDeleted)throw ue.create("server-app-deleted")}}const ge="10.12.5";function be(e,t={}){let r=e;if("object"!=typeof t){const i=t;t={name:i}}var n=Object.assign({name:re,automaticDataCollectionEnabled:!1},t);const i=n.name;if("string"!=typeof i||!i)throw ue.create("bad-app-name",{appName:String(i)});if(r=r||h(),!r)throw ue.create("no-options");var a=ie.get(i);if(a){if(b(r,a.options)&&b(n,a.config))return a;throw ue.create("duplicate-app",{appName:i})}const s=new D(i);for(const o of se.values())s.addComponent(o);n=new pe(r,n,s);return ie.set(i,n),n}async function me(e){let t=!1;var r=e.name;if(ie.has(r))t=!0,ie.delete(r);else if(ae.has(r)){const n=e;n.decRefCount()<=0&&(ae.delete(r),t=!0)}t&&(await Promise.all(e.container.getProviders().map(e=>e.delete())),e.isDeleted=!0)}function ve(e,t,r){let n=null!==(a=ne[e])&&void 0!==a?a:e;r&&(n+=`-${r}`);var i=n.match(/\s|\//),a=t.match(/\s|\//);if(i||a){const s=[`Unable to register library "${n}" with version "${t}":`];return i&&s.push(`library name "${n}" contains illegal characters (whitespace or "/")`),i&&a&&s.push("and"),a&&s.push(`version name "${t}" contains illegal characters (whitespace or "/")`),void ee.warn(s.join(" "))}le(new E(`${n}-version`,()=>({library:n,version:t}),"VERSION"))}function _e(e,t){if(null!==e&&"function"!=typeof e)throw ue.create("invalid-log-argument");!function(a,e){for(const t of I){let i=null;e&&e.level&&(i=L[e.level]),t.userLogHandler=null===a?null:(e,t,...r)=>{var n=r.map(e=>{if(null==e)return null;if("string"==typeof e)return e;if("number"==typeof e||"boolean"==typeof e)return e.toString();if(e instanceof Error)return e.message;try{return JSON.stringify(e)}catch(e){return null}}).filter(e=>e).join(" ");t>=(null!==i&&void 0!==i?i:e.logLevel)&&a({level:S[t].toLowerCase(),message:n,args:r,type:e.name})}}}(e,t)}function ye(e){var t;t=e,I.forEach(e=>{e.setLogLevel(t)})}const Ee="firebase-heartbeat-database",we=1,Ce="firebase-heartbeat-store";let De=null;function Ie(){return De=De||function(e,t,{blocked:r,upgrade:n,blocking:i,terminated:a}){const s=indexedDB.open(e,t),o=U(s);return n&&s.addEventListener("upgradeneeded",e=>{n(U(s.result),e.oldVersion,e.newVersion,U(s.transaction),e)}),r&&s.addEventListener("blocked",e=>r(e.oldVersion,e.newVersion,e)),o.then(e=>{a&&e.addEventListener("close",()=>a()),i&&e.addEventListener("versionchange",e=>i(e.oldVersion,e.newVersion,e))}).catch(()=>{}),o}(Ee,we,{upgrade:(e,t)=>{if(0===t)try{e.createObjectStore(Ce)}catch(e){console.warn(e)}}}).catch(e=>{throw ue.create("idb-open",{originalErrorMessage:e.message})}),De}async function Se(e,t){try{const n=await Ie(),i=n.transaction(Ce,"readwrite"),a=i.objectStore(Ce);await a.put(t,Oe(e)),await i.done}catch(e){var r;e instanceof o?ee.warn(e.message):(r=ue.create("idb-set",{originalErrorMessage:null==e?void 0:e.message}),ee.warn(r.message))}}function Oe(e){return`${e.name}!${e.options.appId}`}class Ae{constructor(e){this.container=e,this._heartbeatsCache=null;var t=this.container.getProvider("app").getImmediate();this._storage=new Ne(t),this._heartbeatsCachePromise=this._storage.read().then(e=>this._heartbeatsCache=e)}async triggerHeartbeat(){var e;const t=this.container.getProvider("platform-logger").getImmediate();var r=t.getPlatformInfoString();const n=Le();if((null!=(null===(e=this._heartbeatsCache)||void 0===e?void 0:e.heartbeats)||(this._heartbeatsCache=await this._heartbeatsCachePromise,null!=(null===(e=this._heartbeatsCache)||void 0===e?void 0:e.heartbeats)))&&this._heartbeatsCache.lastSentHeartbeatDate!==n&&!this._heartbeatsCache.heartbeats.some(e=>e.date===n))return this._heartbeatsCache.heartbeats.push({date:n,agent:r}),this._heartbeatsCache.heartbeats=this._heartbeatsCache.heartbeats.filter(e=>{var t=new Date(e.date).valueOf();return Date.now()-t<=2592e6}),this._storage.overwrite(this._heartbeatsCache)}async getHeartbeatsHeader(){if(null===this._heartbeatsCache&&await this._heartbeatsCachePromise,null==(null===(r=this._heartbeatsCache)||void 0===r?void 0:r.heartbeats)||0===this._heartbeatsCache.heartbeats.length)return"";var e=Le(),{heartbeatsToSend:t,unsentEntries:r}=function(e,t=1024){const r=[];let n=e.slice();for(const i of e){const a=r.find(e=>e.agent===i.agent);if(a){if(a.dates.push(i.date),Be(r)>t){a.dates.pop();break}}else if(r.push({agent:i.agent,dates:[i.date]}),Be(r)>t){r.pop();break}n=n.slice(1)}return{heartbeatsToSend:r,unsentEntries:n}}(this._heartbeatsCache.heartbeats),t=i(JSON.stringify({version:2,heartbeats:t}));return this._heartbeatsCache.lastSentHeartbeatDate=e,0<r.length?(this._heartbeatsCache.heartbeats=r,await this._storage.overwrite(this._heartbeatsCache)):(this._heartbeatsCache.heartbeats=[],this._storage.overwrite(this._heartbeatsCache)),t}}function Le(){const e=new Date;return e.toISOString().substring(0,10)}class Ne{constructor(e){this.app=e,this._canUseIndexedDBPromise=this.runIndexedDBEnvironmentCheck()}async runIndexedDBEnvironmentCheck(){return!!function(){try{return"object"==typeof indexedDB}catch(e){return}}()&&new Promise((t,r)=>{try{let e=!0;const n="validate-browser-context-for-indexeddb-analytics-module",i=self.indexedDB.open(n);i.onsuccess=()=>{i.result.close(),e||self.indexedDB.deleteDatabase(n),t(!0)},i.onupgradeneeded=()=>{e=!1},i.onerror=()=>{var e;r((null===(e=i.error)||void 0===e?void 0:e.message)||"")}}catch(e){r(e)}}).then(()=>!0).catch(()=>!1)}async read(){if(await this._canUseIndexedDBPromise){var e=await async function(e){try{const r=await Ie(),n=r.transaction(Ce);var t=await n.objectStore(Ce).get(Oe(e));return await n.done,t}catch(e){e instanceof o?ee.warn(e.message):(t=ue.create("idb-get",{originalErrorMessage:null==e?void 0:e.message}),ee.warn(t.message))}}(this.app);return null!=e&&e.heartbeats?e:{heartbeats:[]}}return{heartbeats:[]}}async overwrite(e){var t;if(await this._canUseIndexedDBPromise){var r=await this.read();return Se(this.app,{lastSentHeartbeatDate:null!==(t=e.lastSentHeartbeatDate)&&void 0!==t?t:r.lastSentHeartbeatDate,heartbeats:e.heartbeats})}}async add(e){var t;if(await this._canUseIndexedDBPromise){var r=await this.read();return Se(this.app,{lastSentHeartbeatDate:null!==(t=e.lastSentHeartbeatDate)&&void 0!==t?t:r.lastSentHeartbeatDate,heartbeats:[...r.heartbeats,...e.heartbeats]})}}}function Be(e){return i(JSON.stringify({version:2,heartbeats:e})).length}te="",le(new E("platform-logger",e=>new q(e),"PRIVATE")),le(new E("heartbeat",e=>new Ae(e),"PRIVATE")),ve(Z,Q,te),ve(Z,Q,"esm2017"),ve("fire-js","");var Te=Object.freeze({__proto__:null,SDK_VERSION:ge,_DEFAULT_ENTRY_NAME:re,_addComponent:oe,_addOrOverwriteComponent:ce,_apps:ie,_clearComponents:function(){se.clear()},_components:se,_getProvider:he,_isFirebaseApp:de,_isFirebaseServerApp:function(e){return void 0!==e.settings},_registerComponent:le,_removeServiceInstance:function(e,t,r=re){he(e,t).clearInstance(r)},_serverApps:ae,deleteApp:me,getApp:function(e=re){var t=ie.get(e);if(!t&&e===re&&h())return be();if(!t)throw ue.create("no-app",{appName:e});return t},getApps:function(){return Array.from(ie.values())},initializeApp:be,initializeServerApp:function(e,t){if(d()&&!u())throw ue.create("invalid-server-app-environment");void 0===t.automaticDataCollectionEnabled&&(t.automaticDataCollectionEnabled=!1);let r;r=de(e)?e.options:e;const n=Object.assign(Object.assign({},t),r);if(void 0!==n.releaseOnDeref&&delete n.releaseOnDeref,void 0!==t.releaseOnDeref&&"undefined"==typeof FinalizationRegistry)throw ue.create("finalization-registry-not-supported",{});var i=""+[...JSON.stringify(n)].reduce((e,t)=>Math.imul(31,e)+t.charCodeAt(0)|0,0);const a=ae.get(i);if(a)return a.incRefCount(t.releaseOnDeref),a;const s=new D(i);for(const c of se.values())s.addComponent(c);var o=new fe(r,t,i,s);return ae.set(i,o),o},onLog:_e,registerVersion:ve,setLogLevel:ye,FirebaseError:o});class Re{constructor(e,t){this._delegate=e,this.firebase=t,oe(e,new E("app-compat",()=>this,"PUBLIC")),this.container=e.container}get automaticDataCollectionEnabled(){return this._delegate.automaticDataCollectionEnabled}set automaticDataCollectionEnabled(e){this._delegate.automaticDataCollectionEnabled=e}get name(){return this._delegate.name}get options(){return this._delegate.options}delete(){return new Promise(e=>{this._delegate.checkDestroyed(),e()}).then(()=>(this.firebase.INTERNAL.removeApp(this.name),me(this._delegate)))}_getService(e,t=re){var r;this._delegate.checkDestroyed();const n=this._delegate.container.getProvider(e);return n.isInitialized()||"EXPLICIT"!==(null===(r=n.getComponent())||void 0===r?void 0:r.instantiationMode)||n.initialize(),n.getImmediate({identifier:t})}_removeServiceInstance(e,t=re){this._delegate.container.getProvider(e).clearInstance(t)}_addComponent(e){oe(this._delegate,e)}_addOrOverwriteComponent(e){ce(this._delegate,e)}toJSON(){return{name:this.name,automaticDataCollectionEnabled:this.automaticDataCollectionEnabled,options:this.options}}}const Pe=new p("app-compat","Firebase",{"no-app":"No Firebase App '{$appName}' has been created - call Firebase App.initializeApp()","invalid-app-argument":"firebase.{$appName}() takes either no argument or a Firebase App instance."});function ke(i){const a={},s={__esModule:!0,initializeApp:function(e,t={}){var r=be(e,t);if(g(a,r.name))return a[r.name];var n=new i(r,s);return a[r.name]=n},app:o,registerVersion:ve,setLogLevel:ye,onLog:_e,apps:null,SDK_VERSION:ge,INTERNAL:{registerComponent:function(r){const n=r.name,t=n.replace("-compat","");{var e;le(r)&&"PUBLIC"===r.type&&(e=(e=o())=>{if("function"!=typeof e[t])throw Pe.create("invalid-app-argument",{appName:n});return e[t]()},void 0!==r.serviceProps&&c(e,r.serviceProps),s[t]=e,i.prototype[t]=function(...e){const t=this._getService.bind(this,n);return t.apply(this,r.multipleInstances?e:[])})}return"PUBLIC"===r.type?s[t]:null},removeApp:function(e){delete a[e]},useAsService:function(e,t){if("serverAuth"===t)return null;var r=t;return r},modularAPIs:Te}};function o(e){if(e=e||re,!g(a,e))throw Pe.create("no-app",{appName:e});return a[e]}return s.default=s,Object.defineProperty(s,"apps",{get:function(){return Object.keys(a).map(e=>a[e])}}),o.App=i,s}var Me=function e(){const t=ke(Re);return t.INTERNAL=Object.assign(Object.assign({},t.INTERNAL),{createFirebaseNamespace:e,extendNamespace:function(e){c(t,e)},createSubscribe:v,ErrorFactory:p,deepExtend:c}),t}();const Fe=new R("@firebase/app-compat");if(d()&&void 0!==window.firebase){Fe.warn(` + Warning: Firebase is already defined in the global scope. Please make sure + Firebase library is only loaded once. + `);const ze=window.firebase.SDK_VERSION;ze&&0<=ze.indexOf("LITE")&&Fe.warn(` + Warning: You are trying to load Firebase while using Firebase Performance standalone script. + You should load Firebase Performance with this instance of Firebase to avoid loading duplicate code. + `)}const je=Me;ve("@firebase/app-compat","0.2.38",void 0);return je.registerVersion("firebase","10.12.5","app-compat-cdn"),je}); +//# sourceMappingURL=firebase-app-compat.js.map diff --git a/TaskTrackingSystem.WebApp/wwwroot/firebase-config.js b/TaskTrackingSystem.WebApp/wwwroot/firebase-config.js new file mode 100644 index 0000000000000000000000000000000000000000..a3bd96f35f2d08c397fda645314713b3bf195b11 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/firebase-config.js @@ -0,0 +1,10 @@ +self.ttsFirebaseConfig = { + apiKey: "AIzaSyAsudRlYNKctjvctfr4Vb84wWodUYSv5NQ", + authDomain: "ttsys-726b5.firebaseapp.com", + projectId: "ttsys-726b5", + storageBucket: "ttsys-726b5.firebasestorage.app", + messagingSenderId: "517093039925", + appId: "1:517093039925:web:08df7153a8f05ceb82b67c", + measurementId: "G-0TVHXFR6WB", + vapidKey: "BP8VcUO8HousJEE2s8yG7jBnL3tRfZOyK2cNMYX93SIdxefSytuOheHkfkjN8MVOu4GGhyKbb3OaAADoLlsZxqs" +}; diff --git a/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging-compat.js b/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging-compat.js new file mode 100644 index 0000000000000000000000000000000000000000..845b07fc61258d49310b0603de3195a9b4190587 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging-compat.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("@firebase/app-compat"),require("@firebase/app")):"function"==typeof define&&define.amd?define(["@firebase/app-compat","@firebase/app"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).firebase,e.firebase.INTERNAL.modularAPIs)}(this,function(Ut,Gt){"use strict";try{!(function(){function e(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}var i,t=e(Ut);function n(){try{return"object"==typeof indexedDB}catch(e){return!1}}class o extends Error{constructor(e,t,n){super(t),this.code=e,this.customData=n,this.name="FirebaseError",Object.setPrototypeOf(this,o.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,a.prototype.create)}}class a{constructor(e,t,n){this.service=e,this.serviceName=t,this.errors=n}create(e,...t){var i,n=t[0]||{},a=`${this.service}/${e}`,r=this.errors[e],r=r?(i=n,r.replace(s,(e,t)=>{var n=i[t];return null!=n?String(n):`<${t}?>`})):"Error",r=`${this.serviceName}: ${r} (${a}).`;return new o(a,r,n)}}const s=/\{\$([^}]+)}/g;function r(e){return e&&e._delegate?e._delegate:e}class c{constructor(e,t,n){this.name=e,this.instanceFactory=t,this.type=n,this.multipleInstances=!1,this.serviceProps={},this.instantiationMode="LAZY",this.onInstanceCreated=null}setInstantiationMode(e){return this.instantiationMode=e,this}setMultipleInstances(e){return this.multipleInstances=e,this}setServiceProps(e){return this.serviceProps=e,this}setInstanceCreatedCallback(e){return this.onInstanceCreated=e,this}}const u=(t,e)=>e.some(e=>t instanceof e);let d,p;const l=new WeakMap,f=new WeakMap,g=new WeakMap,w=new WeakMap,h=new WeakMap;let v={get(e,t,n){if(e instanceof IDBTransaction){if("done"===t)return f.get(e);if("objectStoreNames"===t)return e.objectStoreNames||g.get(e);if("store"===t)return n.objectStoreNames[1]?void 0:n.objectStore(n.objectStoreNames[0])}return y(e[t])},set(e,t,n){return e[t]=n,!0},has(e,t){return e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e}};function m(i){return i!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(p=p||[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey]).includes(i)?function(...e){return i.apply(k(this),e),y(l.get(this))}:function(...e){return y(i.apply(k(this),e))}:function(e,...t){var n=i.call(k(this),e,...t);return g.set(n,e.sort?e.sort():[e]),y(n)}}function b(e){return"function"==typeof e?m(e):(e instanceof IDBTransaction&&(r=e,f.has(r)||(t=new Promise((e,t)=>{const n=()=>{r.removeEventListener("complete",i),r.removeEventListener("error",a),r.removeEventListener("abort",a)},i=()=>{e(),n()},a=()=>{t(r.error||new DOMException("AbortError","AbortError")),n()};r.addEventListener("complete",i),r.addEventListener("error",a),r.addEventListener("abort",a)}),f.set(r,t))),u(e,d=d||[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])?new Proxy(e,v):e);var r,t}function y(e){if(e instanceof IDBRequest)return function(r){const e=new Promise((e,t)=>{const n=()=>{r.removeEventListener("success",i),r.removeEventListener("error",a)},i=()=>{e(y(r.result)),n()},a=()=>{t(r.error),n()};r.addEventListener("success",i),r.addEventListener("error",a)});return e.then(e=>{e instanceof IDBCursor&&l.set(e,r)}).catch(()=>{}),h.set(e,r),e}(e);if(w.has(e))return w.get(e);var t=b(e);return t!==e&&(w.set(e,t),h.set(t,e)),t}const k=e=>h.get(e);function I(e,t,{blocked:n,upgrade:i,blocking:a,terminated:r}={}){const o=indexedDB.open(e,t),s=y(o);return i&&o.addEventListener("upgradeneeded",e=>{i(y(o.result),e.oldVersion,e.newVersion,y(o.transaction),e)}),n&&o.addEventListener("blocked",e=>n(e.oldVersion,e.newVersion,e)),s.then(e=>{r&&e.addEventListener("close",()=>r()),a&&e.addEventListener("versionchange",e=>a(e.oldVersion,e.newVersion,e))}).catch(()=>{}),s}function S(e,{blocked:t}={}){const n=indexedDB.deleteDatabase(e);return t&&n.addEventListener("blocked",e=>t(e.oldVersion,e)),y(n).then(()=>{})}const T=["get","getKey","getAll","getAllKeys","count"],C=["put","add","delete","clear"],D=new Map;function _(e,t){if(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t){if(D.get(t))return D.get(t);const a=t.replace(/FromIndex$/,""),r=t!==a,o=C.includes(a);if(a in(r?IDBIndex:IDBObjectStore).prototype&&(o||T.includes(a))){var n=async function(e,...t){var n=this.transaction(e,o?"readwrite":"readonly");let i=n.store;return r&&(i=i.index(t.shift())),(await Promise.all([i[a](...t),o&&n.done]))[0]};return D.set(t,n),n}}}v={...i=v,get:(e,t,n)=>_(e,t)||i.get(e,t,n),has:(e,t)=>!!_(e,t)||i.has(e,t)};var j="@firebase/installations",O="0.6.8";const P=1e4,E=`w:${O}`,M="FIS_v2",A="https://firebaseinstallations.googleapis.com/v1",K=36e5;var N,x,L,B,$;const F=new a("installations","Installations",{"missing-app-config-values":'Missing App configuration value: "{$valueName}"',"not-registered":"Firebase Installation is not registered.","installation-not-found":"Firebase Installation not found.","request-failed":'{$requestName} request failed with error "{$serverCode} {$serverStatus}: {$serverMessage}"',"app-offline":"Could not process request. Application offline.","delete-pending-registration":"Can't delete installation while there is a pending registration request."});function H(e){return e instanceof o&&e.code.includes("request-failed")}function R({projectId:e}){return`${A}/projects/${e}/installations`}function q(e){return{token:e.token,requestStatus:2,expiresIn:(e=e.expiresIn,Number(e.replace("s","000"))),creationTime:Date.now()}}async function V(e,t){var n=(await t.json()).error;return F.create("request-failed",{requestName:e,serverCode:n.code,serverMessage:n.message,serverStatus:n.status})}function W({apiKey:e}){return new Headers({"Content-Type":"application/json",Accept:"application/json","x-goog-api-key":e})}function U(e,{refreshToken:t}){const n=W(e);return n.append("Authorization",(t=t,`${M} ${t}`)),n}async function G(e){var t=await e();return 500<=t.status&&t.status<600?e():t}function J(t){return new Promise(e=>{setTimeout(e,t)})}const z=/^[cdef][\w-]{21}$/,Y="";function Q(){try{const t=new Uint8Array(17),n=self.crypto||self.msCrypto;n.getRandomValues(t),t[0]=112+t[0]%16;var e=function(e){const t=function(e){const t=btoa(String.fromCharCode(...e));return t.replace(/\+/g,"-").replace(/\//g,"_")}(e);return t.substr(0,22)}(t);return z.test(e)?e:Y}catch(e){return Y}}function Z(e){return`${e.appName}!${e.appId}`}const X=new Map;function ee(e,t){var n=Z(e);te(n,t),function(e,t){const n=function(){!ne&&"BroadcastChannel"in self&&(ne=new BroadcastChannel("[Firebase] FID Change"),ne.onmessage=e=>{te(e.data.key,e.data.fid)});return ne}();n&&n.postMessage({key:e,fid:t});0===X.size&&ne&&(ne.close(),ne=null)}(n,t)}function te(e,t){var n=X.get(e);if(n)for(const i of n)i(t)}let ne=null;const ie="firebase-installations-store";let ae=null;function re(){return ae=ae||I("firebase-installations-database",1,{upgrade:(e,t)=>{0===t&&e.createObjectStore(ie)}}),ae}async function oe(e,t){var n=Z(e);const i=await re(),a=i.transaction(ie,"readwrite"),r=a.objectStore(ie);var o=await r.get(n);return await r.put(t,n),await a.done,o&&o.fid===t.fid||ee(e,t.fid),t}async function se(e){var t=Z(e);const n=await re(),i=n.transaction(ie,"readwrite");await i.objectStore(ie).delete(t),await i.done}async function ce(e,t){var n=Z(e);const i=await re(),a=i.transaction(ie,"readwrite"),r=a.objectStore(ie);var o=await r.get(n),s=t(o);return void 0===s?await r.delete(n):await r.put(s,n),await a.done,!s||o&&o.fid===s.fid||ee(e,s.fid),s}async function ue(n){let i;var e=await ce(n.appConfig,e=>{var t=pe(e||{fid:Q(),registrationStatus:0}),t=function(e,t){{if(0!==t.registrationStatus)return 1===t.registrationStatus?{installationEntry:t,registrationPromise:async function(e){let t=await de(e.appConfig);for(;1===t.registrationStatus;)await J(100),t=await de(e.appConfig);if(0!==t.registrationStatus)return t;{var{installationEntry:n,registrationPromise:i}=await ue(e);return i||n}}(e)}:{installationEntry:t};if(!navigator.onLine){var n=Promise.reject(F.create("app-offline"));return{installationEntry:t,registrationPromise:n}}var i={fid:t.fid,registrationStatus:1,registrationTime:Date.now()},n=async function(t,n){try{var e=await async function({appConfig:e,heartbeatServiceProvider:t},{fid:n}){const i=R(e),a=W(e),r=t.getImmediate({optional:!0});!r||(o=await r.getHeartbeatsHeader())&&a.append("x-firebase-client",o);var o={fid:n,authVersion:M,appId:e.appId,sdkVersion:E};const s={method:"POST",headers:a,body:JSON.stringify(o)},c=await G(()=>fetch(i,s));if(c.ok){o=await c.json();return{fid:o.fid||n,registrationStatus:2,refreshToken:o.refreshToken,authToken:q(o.authToken)}}throw await V("Create Installation",c)}(t,n);return oe(t.appConfig,e)}catch(e){throw H(e)&&409===e.customData.serverCode?await se(t.appConfig):await oe(t.appConfig,{fid:n.fid,registrationStatus:0}),e}}(e,i);return{installationEntry:i,registrationPromise:n}}}(n,t);return i=t.registrationPromise,t.installationEntry});return e.fid===Y?{installationEntry:await i}:{installationEntry:e,registrationPromise:i}}function de(e){return ce(e,e=>{if(!e)throw F.create("installation-not-found");return pe(e)})}function pe(e){return 1===(t=e).registrationStatus&&t.registrationTime+P<Date.now()?{fid:e.fid,registrationStatus:0}:e;var t}async function le({appConfig:e,heartbeatServiceProvider:t},n){const i=([a,r]=[e,n["fid"]],`${R(a)}/${r}/authTokens:generate`);var a,r;const o=U(e,n),s=t.getImmediate({optional:!0});!s||(c=await s.getHeartbeatsHeader())&&o.append("x-firebase-client",c);var c={installation:{sdkVersion:E,appId:e.appId}};const u={method:"POST",headers:o,body:JSON.stringify(c)},d=await G(()=>fetch(i,u));if(d.ok)return q(await d.json());throw await V("Generate Auth Token",d)}async function fe(i,a=!1){let r;var e=await ce(i.appConfig,e=>{if(!we(e))throw F.create("not-registered");var t,n=e.authToken;if(a||2!==(t=n).requestStatus||function(e){var t=Date.now();return t<e.creationTime||e.creationTime+e.expiresIn<t+K}(t)){if(1===n.requestStatus)return r=async function(e,t){let n=await ge(e.appConfig);for(;1===n.authToken.requestStatus;)await J(100),n=await ge(e.appConfig);var i=n.authToken;return 0===i.requestStatus?fe(e,t):i}(i,a),e;if(!navigator.onLine)throw F.create("app-offline");n=(t=e,n={requestStatus:1,requestTime:Date.now()},Object.assign(Object.assign({},t),{authToken:n}));return r=async function(t,n){try{var i=await le(t,n),e=Object.assign(Object.assign({},n),{authToken:i});return await oe(t.appConfig,e),i}catch(e){throw!H(e)||401!==e.customData.serverCode&&404!==e.customData.serverCode?(i=Object.assign(Object.assign({},n),{authToken:{requestStatus:0}}),await oe(t.appConfig,i)):await se(t.appConfig),e}}(i,n),n}return e});return r?await r:e.authToken}function ge(e){return ce(e,e=>{if(!we(e))throw F.create("not-registered");var t,n=e.authToken;return 1===(t=n).requestStatus&&t.requestTime+P<Date.now()?Object.assign(Object.assign({},e),{authToken:{requestStatus:0}}):e})}function we(e){return void 0!==e&&2===e.registrationStatus}async function he(e,t=!1){var n,i=e;return await((n=(await ue(i)).registrationPromise)&&await n),(await fe(i,t)).token}function ve(e){return F.create("missing-app-config-values",{valueName:e})}const me="installations",be=e=>{var t=e.getProvider("app").getImmediate();return{app:t,appConfig:function(e){if(!e||!e.options)throw ve("App Configuration");if(!e.name)throw ve("App Name");for(const t of["projectId","apiKey","appId"])if(!e.options[t])throw ve(t);return{appName:e.name,projectId:e.options.projectId,apiKey:e.options.apiKey,appId:e.options.appId}}(t),heartbeatServiceProvider:Gt._getProvider(t,"heartbeat"),_delete:()=>Promise.resolve()}},ye=e=>{var t=e.getProvider("app").getImmediate();const n=Gt._getProvider(t,me).getImmediate();return{getId:()=>async function(e){var t=e;const{installationEntry:n,registrationPromise:i}=await ue(t);return(i||fe(t)).catch(console.error),n.fid}(n),getToken:e=>he(n,e)}};Gt._registerComponent(new c(me,be,"PUBLIC")),Gt._registerComponent(new c("installations-internal",ye,"PRIVATE")),Gt.registerVersion(j,O),Gt.registerVersion(j,O,"esm2017");const ke="/firebase-messaging-sw.js",Ie="/firebase-cloud-messaging-push-scope",Se="BDOU99-h67HcA6JeFXHbSNMu7e2yNNu3RzoMj8TM4W88jITfq7ZmPvIM1Iv-4_l2LxQcYwhqby2xGpWwzjfAnG4",Te="https://fcmregistrations.googleapis.com/v1",Ce="google.c.a.c_id",De="google.c.a.e";function _e(e){var t=new Uint8Array(e);const n=btoa(String.fromCharCode(...t));return n.replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}(N=$=$||{})[N.DATA_MESSAGE=1]="DATA_MESSAGE",N[N.DISPLAY_NOTIFICATION=3]="DISPLAY_NOTIFICATION",($=x=x||{}).PUSH_RECEIVED="push-received",$.NOTIFICATION_CLICKED="notification-clicked";const je="fcm_token_details_db",Oe="fcm_token_object_Store";async function Pe(c){if("databases"in indexedDB){const t=await indexedDB.databases(),n=t.map(e=>e.name);if(!n.includes(je))return null}let u=null;const e=await I(je,5,{upgrade:async(e,t,n,i)=>{var a;if(!(t<2)&&e.objectStoreNames.contains(Oe)){const s=i.objectStore(Oe);var r,o=await s.index("fcmSenderId").get(c);await s.clear(),o&&(2===t?(r=o).auth&&r.p256dh&&r.endpoint&&(u={token:r.fcmToken,createTime:null!==(a=r.createTime)&&void 0!==a?a:Date.now(),subscriptionOptions:{auth:r.auth,p256dh:r.p256dh,endpoint:r.endpoint,swScope:r.swScope,vapidKey:"string"==typeof r.vapidKey?r.vapidKey:_e(r.vapidKey)}}):3===t?(r=o,u={token:r.fcmToken,createTime:r.createTime,subscriptionOptions:{auth:_e(r.auth),p256dh:_e(r.p256dh),endpoint:r.endpoint,swScope:r.swScope,vapidKey:_e(r.vapidKey)}}):4===t&&(o=o,u={token:o.fcmToken,createTime:o.createTime,subscriptionOptions:{auth:_e(o.auth),p256dh:_e(o.p256dh),endpoint:o.endpoint,swScope:o.swScope,vapidKey:_e(o.vapidKey)}}))}}});return e.close(),await S(je),await S("fcm_vapid_details_db"),await S("undefined"),function(e){if(!e||!e.subscriptionOptions)return!1;var t=e["subscriptionOptions"];return"number"==typeof e.createTime&&0<e.createTime&&"string"==typeof e.token&&0<e.token.length&&"string"==typeof t.auth&&0<t.auth.length&&"string"==typeof t.p256dh&&0<t.p256dh.length&&"string"==typeof t.endpoint&&0<t.endpoint.length&&"string"==typeof t.swScope&&0<t.swScope.length&&"string"==typeof t.vapidKey&&0<t.vapidKey.length}(u)?u:null}const Ee="firebase-messaging-database",Me=1,Ae="firebase-messaging-store";let Ke=null;function Ne(){return Ke=Ke||I(Ee,Me,{upgrade:(e,t)=>{0===t&&e.createObjectStore(Ae)}}),Ke}async function xe(e){var t=Be(e);const n=await Ne();t=await n.transaction(Ae).objectStore(Ae).get(t);if(t)return t;t=await Pe(e.appConfig.senderId);return t?(await Le(e,t),t):void 0}async function Le(e,t){var n=Be(e);const i=await Ne(),a=i.transaction(Ae,"readwrite");return await a.objectStore(Ae).put(t,n),await a.done,t}function Be({appConfig:e}){return e.appId}const $e=new a("messaging","Messaging",{"missing-app-config-values":'Missing App configuration value: "{$valueName}"',"only-available-in-window":"This method is available in a Window context.","only-available-in-sw":"This method is available in a service worker context.","permission-default":"The notification permission was not granted and dismissed instead.","permission-blocked":"The notification permission was not granted and blocked instead.","unsupported-browser":"This browser doesn't support the API's required to use the Firebase SDK.","indexed-db-unsupported":"This browser doesn't support indexedDb.open() (ex. Safari iFrame, Firefox Private Browsing, etc)","failed-service-worker-registration":"We are unable to register the default service worker. {$browserErrorMessage}","token-subscribe-failed":"A problem occurred while subscribing the user to FCM: {$errorInfo}","token-subscribe-no-token":"FCM returned no token when subscribing the user to push.","token-unsubscribe-failed":"A problem occurred while unsubscribing the user from FCM: {$errorInfo}","token-update-failed":"A problem occurred while updating the user from FCM: {$errorInfo}","token-update-no-token":"FCM returned no token when updating the user to push.","use-sw-after-get-token":"The useServiceWorker() method may only be called once and must be called before calling getToken() to ensure your service worker is used.","invalid-sw-registration":"The input to useServiceWorker() must be a ServiceWorkerRegistration.","invalid-bg-handler":"The input to setBackgroundMessageHandler() must be a function.","invalid-vapid-key":"The public VAPID key must be a string.","use-vapid-key-after-get-token":"The usePublicVapidKey() method may only be called once and must be called before calling getToken() to ensure your VAPID key is used."});async function Fe(e,t){var n={method:"DELETE",headers:await Re(e)};try{const r=await fetch(`${He(e.appConfig)}/${t}`,n);var i=await r.json();if(i.error){var a=i.error.message;throw $e.create("token-unsubscribe-failed",{errorInfo:a})}}catch(e){throw $e.create("token-unsubscribe-failed",{errorInfo:null==e?void 0:e.toString()})}}function He({projectId:e}){return`${Te}/projects/${e}/registrations`}async function Re({appConfig:e,installations:t}){var n=await t.getToken();return new Headers({"Content-Type":"application/json",Accept:"application/json","x-goog-api-key":e.apiKey,"x-goog-firebase-installations-auth":`FIS ${n}`})}function qe({p256dh:e,auth:t,endpoint:n,vapidKey:i}){const a={web:{endpoint:n,auth:t,p256dh:e}};return i!==Se&&(a.web.applicationPubKey=i),a}const Ve=6048e5;async function We(e){const t=await async function(e,t){var n=await e.pushManager.getSubscription();if(n)return n;return e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:function(e){var t=(e+"=".repeat((4-e.length%4)%4)).replace(/\-/g,"+").replace(/_/g,"/");const n=atob(t),i=new Uint8Array(n.length);for(let a=0;a<n.length;++a)i[a]=n.charCodeAt(a);return i}(t)})}(e.swRegistration,e.vapidKey);var n,i,a,r,o,s={vapidKey:e.vapidKey,swScope:e.swRegistration.scope,endpoint:t.endpoint,auth:_e(t.getKey("auth")),p256dh:_e(t.getKey("p256dh"))},c=await xe(e.firebaseDependencies);if(c){if(n=c.subscriptionOptions,i=s.vapidKey===n.vapidKey,a=s.endpoint===n.endpoint,r=s.auth===n.auth,o=s.p256dh===n.p256dh,i&&a&&r&&o)return Date.now()>=c.createTime+Ve?async function(e,t){try{var n=await async function(e,t){var n=await Re(e),i=qe(t.subscriptionOptions),i={method:"PATCH",headers:n,body:JSON.stringify(i)};let a;try{const r=await fetch(`${He(e.appConfig)}/${t.token}`,i);a=await r.json()}catch(e){throw $e.create("token-update-failed",{errorInfo:null==e?void 0:e.toString()})}if(a.error){i=a.error.message;throw $e.create("token-update-failed",{errorInfo:i})}if(!a.token)throw $e.create("token-update-no-token");return a.token}(e.firebaseDependencies,t),i=Object.assign(Object.assign({},t),{token:n,createTime:Date.now()});return await Le(e.firebaseDependencies,i),n}catch(e){throw e}}(e,{token:c.token,createTime:Date.now(),subscriptionOptions:s}):c.token;try{await Fe(e.firebaseDependencies,c.token)}catch(e){console.warn(e)}return Ge(e.firebaseDependencies,s)}return Ge(e.firebaseDependencies,s)}async function Ue(e){var t=await xe(e.firebaseDependencies);t&&(await Fe(e.firebaseDependencies,t.token),await async function(e){var t=Be(e);const n=await Ne(),i=n.transaction(Ae,"readwrite");await i.objectStore(Ae).delete(t),await i.done}(e.firebaseDependencies));const n=await e.swRegistration.pushManager.getSubscription();return!n||n.unsubscribe()}async function Ge(e,t){var n={token:await async function(e,t){var n=await Re(e),i=qe(t),i={method:"POST",headers:n,body:JSON.stringify(i)};let a;try{const r=await fetch(He(e.appConfig),i);a=await r.json()}catch(e){throw $e.create("token-subscribe-failed",{errorInfo:null==e?void 0:e.toString()})}if(a.error){i=a.error.message;throw $e.create("token-subscribe-failed",{errorInfo:i})}if(!a.token)throw $e.create("token-subscribe-no-token");return a.token}(e,t),createTime:Date.now(),subscriptionOptions:t};return await Le(e,n),n.token}function Je(e){var t,n,i,a,r,o={from:e.from,collapseKey:e.collapse_key,messageId:e.fcmMessageId};return i=o,(n=e).notification&&(i.notification={},(t=n.notification.title)&&(i.notification.title=t),(t=n.notification.body)&&(i.notification.body=t),(t=n.notification.image)&&(i.notification.image=t),(t=n.notification.icon)&&(i.notification.icon=t)),n=o,(i=e).data&&(n.data=i.data),i=o,((e=e).fcmOptions||null!==(a=e.notification)&&void 0!==a&&a.click_action)&&(i.fcmOptions={},(r=null!==(a=null===(a=e.fcmOptions)||void 0===a?void 0:a.link)&&void 0!==a?a:null===(r=e.notification)||void 0===r?void 0:r.click_action)&&(i.fcmOptions.link=r),(r=null===(r=e.fcmOptions)||void 0===r?void 0:r.analytics_label)&&(i.fcmOptions.analyticsLabel=r)),o}function ze(e,t){const n=[];for(let i=0;i<e.length;i++)n.push(e.charAt(i)),i<t.length&&n.push(t.charAt(i));return n.join("")}function Ye(e){return $e.create("missing-app-config-values",{valueName:e})}ze("hts/frbslgigp.ogepscmv/ieo/eaylg","tp:/ieaeogn-agolai.o/1frlglgc/o"),ze("AzSCbw63g1R0nCw85jG8","Iaya3yLKwmgvh7cF0q4");class Qe{constructor(e,t,n){this.deliveryMetricsExportedToBigQueryEnabled=!1,this.onBackgroundMessageHandler=null,this.onMessageHandler=null,this.logEvents=[],this.isLogServiceStarted=!1;var i=function(e){if(!e||!e.options)throw Ye("App Configuration Object");if(!e.name)throw Ye("App Name");var t=e["options"];for(const n of["projectId","apiKey","appId","messagingSenderId"])if(!t[n])throw Ye(n);return{appName:e.name,projectId:t.projectId,apiKey:t.apiKey,appId:t.appId,senderId:t.messagingSenderId}}(e);this.firebaseDependencies={app:e,appConfig:i,installations:t,analyticsProvider:n}}_delete(){return Promise.resolve()}}async function Ze(e){try{e.swRegistration=await navigator.serviceWorker.register(ke,{scope:Ie}),e.swRegistration.update().catch(()=>{})}catch(e){throw $e.create("failed-service-worker-registration",{browserErrorMessage:null==e?void 0:e.message})}}async function Xe(e,t){if(!navigator)throw $e.create("only-available-in-window");if("default"===Notification.permission&&await Notification.requestPermission(),"granted"!==Notification.permission)throw $e.create("permission-blocked");var n,i;return n=e,await((i=null==t?void 0:t.vapidKey)?n.vapidKey=i:n.vapidKey||(n.vapidKey=Se)),await async function(e,t){if(t||e.swRegistration||await Ze(e),t||!e.swRegistration){if(!(t instanceof ServiceWorkerRegistration))throw $e.create("invalid-sw-registration");e.swRegistration=t}}(e,null==t?void 0:t.serviceWorkerRegistration),We(e)}async function et(e,t,n){var i=function(e){switch(e){case x.NOTIFICATION_CLICKED:return"notification_open";case x.PUSH_RECEIVED:return"notification_foreground";default:throw new Error}}(t);const a=await e.firebaseDependencies.analyticsProvider.get();a.logEvent(i,{message_id:n[Ce],message_name:n["google.c.a.c_l"],message_time:n["google.c.a.ts"],message_device_time:Math.floor(Date.now()/1e3)})}async function tt(e,t){var n,i=t.data;i.isFirebaseMessaging&&(e.onMessageHandler&&i.messageType===x.PUSH_RECEIVED&&("function"==typeof e.onMessageHandler?e.onMessageHandler(Je(i)):e.onMessageHandler.next(Je(i))),n=i.data,"object"==typeof(t=n)&&t&&Ce in t&&"1"===n[De]&&await et(e,i.messageType,n))}const nt="@firebase/messaging",it="0.12.10",at=e=>{const t=new Qe(e.getProvider("app").getImmediate(),e.getProvider("installations-internal").getImmediate(),e.getProvider("analytics-internal"));return navigator.serviceWorker.addEventListener("message",e=>tt(t,e)),t},rt=e=>{const t=e.getProvider("messaging").getImmediate();return{getToken:e=>Xe(t,e)}};function ot(e){return async function(e){if(!navigator)throw $e.create("only-available-in-window");return e.swRegistration||await Ze(e),Ue(e)}(e=r(e))}function st(e,t){return function(e,t){if(!navigator)throw $e.create("only-available-in-window");return e.onMessageHandler=t,()=>{e.onMessageHandler=null}}(e=r(e),t)}Gt._registerComponent(new c("messaging",at,"PUBLIC")),Gt._registerComponent(new c("messaging-internal",rt,"PRIVATE")),Gt.registerVersion(nt,it),Gt.registerVersion(nt,it,"esm2017");const ct="BDOU99-h67HcA6JeFXHbSNMu7e2yNNu3RzoMj8TM4W88jITfq7ZmPvIM1Iv-4_l2LxQcYwhqby2xGpWwzjfAnG4",ut="https://fcmregistrations.googleapis.com/v1",dt="FCM_MSG",pt="google.c.a.c_id",lt=3,ft=1;function gt(e){var t=new Uint8Array(e);const n=btoa(String.fromCharCode(...t));return n.replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}($=L=L||{})[$.DATA_MESSAGE=1]="DATA_MESSAGE",$[$.DISPLAY_NOTIFICATION=3]="DISPLAY_NOTIFICATION",($=B=B||{}).PUSH_RECEIVED="push-received",$.NOTIFICATION_CLICKED="notification-clicked";const wt="fcm_token_details_db",ht="fcm_token_object_Store";async function vt(c){if("databases"in indexedDB){const t=await indexedDB.databases(),n=t.map(e=>e.name);if(!n.includes(wt))return null}let u=null;const e=await I(wt,5,{upgrade:async(e,t,n,i)=>{var a;if(!(t<2)&&e.objectStoreNames.contains(ht)){const s=i.objectStore(ht);var r,o=await s.index("fcmSenderId").get(c);await s.clear(),o&&(2===t?(r=o).auth&&r.p256dh&&r.endpoint&&(u={token:r.fcmToken,createTime:null!==(a=r.createTime)&&void 0!==a?a:Date.now(),subscriptionOptions:{auth:r.auth,p256dh:r.p256dh,endpoint:r.endpoint,swScope:r.swScope,vapidKey:"string"==typeof r.vapidKey?r.vapidKey:gt(r.vapidKey)}}):3===t?(r=o,u={token:r.fcmToken,createTime:r.createTime,subscriptionOptions:{auth:gt(r.auth),p256dh:gt(r.p256dh),endpoint:r.endpoint,swScope:r.swScope,vapidKey:gt(r.vapidKey)}}):4===t&&(o=o,u={token:o.fcmToken,createTime:o.createTime,subscriptionOptions:{auth:gt(o.auth),p256dh:gt(o.p256dh),endpoint:o.endpoint,swScope:o.swScope,vapidKey:gt(o.vapidKey)}}))}}});return e.close(),await S(wt),await S("fcm_vapid_details_db"),await S("undefined"),function(e){if(!e||!e.subscriptionOptions)return!1;var t=e["subscriptionOptions"];return"number"==typeof e.createTime&&0<e.createTime&&"string"==typeof e.token&&0<e.token.length&&"string"==typeof t.auth&&0<t.auth.length&&"string"==typeof t.p256dh&&0<t.p256dh.length&&"string"==typeof t.endpoint&&0<t.endpoint.length&&"string"==typeof t.swScope&&0<t.swScope.length&&"string"==typeof t.vapidKey&&0<t.vapidKey.length}(u)?u:null}const mt="firebase-messaging-database",bt=1,yt="firebase-messaging-store";let kt=null;function It(){return kt=kt||I(mt,bt,{upgrade:(e,t)=>{0===t&&e.createObjectStore(yt)}}),kt}async function St(e){var t=Ct(e);const n=await It();t=await n.transaction(yt).objectStore(yt).get(t);if(t)return t;t=await vt(e.appConfig.senderId);return t?(await Tt(e,t),t):void 0}async function Tt(e,t){var n=Ct(e);const i=await It(),a=i.transaction(yt,"readwrite");return await a.objectStore(yt).put(t,n),await a.done,t}function Ct({appConfig:e}){return e.appId}const Dt=new a("messaging","Messaging",{"missing-app-config-values":'Missing App configuration value: "{$valueName}"',"only-available-in-window":"This method is available in a Window context.","only-available-in-sw":"This method is available in a service worker context.","permission-default":"The notification permission was not granted and dismissed instead.","permission-blocked":"The notification permission was not granted and blocked instead.","unsupported-browser":"This browser doesn't support the API's required to use the Firebase SDK.","indexed-db-unsupported":"This browser doesn't support indexedDb.open() (ex. Safari iFrame, Firefox Private Browsing, etc)","failed-service-worker-registration":"We are unable to register the default service worker. {$browserErrorMessage}","token-subscribe-failed":"A problem occurred while subscribing the user to FCM: {$errorInfo}","token-subscribe-no-token":"FCM returned no token when subscribing the user to push.","token-unsubscribe-failed":"A problem occurred while unsubscribing the user from FCM: {$errorInfo}","token-update-failed":"A problem occurred while updating the user from FCM: {$errorInfo}","token-update-no-token":"FCM returned no token when updating the user to push.","use-sw-after-get-token":"The useServiceWorker() method may only be called once and must be called before calling getToken() to ensure your service worker is used.","invalid-sw-registration":"The input to useServiceWorker() must be a ServiceWorkerRegistration.","invalid-bg-handler":"The input to setBackgroundMessageHandler() must be a function.","invalid-vapid-key":"The public VAPID key must be a string.","use-vapid-key-after-get-token":"The usePublicVapidKey() method may only be called once and must be called before calling getToken() to ensure your VAPID key is used."});async function _t(e,t){var n={method:"DELETE",headers:await Ot(e)};try{const r=await fetch(`${jt(e.appConfig)}/${t}`,n);var i=await r.json();if(i.error){var a=i.error.message;throw Dt.create("token-unsubscribe-failed",{errorInfo:a})}}catch(e){throw Dt.create("token-unsubscribe-failed",{errorInfo:null==e?void 0:e.toString()})}}function jt({projectId:e}){return`${ut}/projects/${e}/registrations`}async function Ot({appConfig:e,installations:t}){var n=await t.getToken();return new Headers({"Content-Type":"application/json",Accept:"application/json","x-goog-api-key":e.apiKey,"x-goog-firebase-installations-auth":`FIS ${n}`})}function Pt({p256dh:e,auth:t,endpoint:n,vapidKey:i}){const a={web:{endpoint:n,auth:t,p256dh:e}};return i!==ct&&(a.web.applicationPubKey=i),a}async function Et(e){const t=await async function(e,t){var n=await e.pushManager.getSubscription();if(n)return n;return e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:function(e){var t=(e+"=".repeat((4-e.length%4)%4)).replace(/\-/g,"+").replace(/_/g,"/");const n=atob(t),i=new Uint8Array(n.length);for(let a=0;a<n.length;++a)i[a]=n.charCodeAt(a);return i}(t)})}(e.swRegistration,e.vapidKey);var n,i,a,r,o,s={vapidKey:e.vapidKey,swScope:e.swRegistration.scope,endpoint:t.endpoint,auth:gt(t.getKey("auth")),p256dh:gt(t.getKey("p256dh"))},c=await St(e.firebaseDependencies);if(c){if(n=c.subscriptionOptions,i=s.vapidKey===n.vapidKey,a=s.endpoint===n.endpoint,r=s.auth===n.auth,o=s.p256dh===n.p256dh,i&&a&&r&&o)return Date.now()>=c.createTime+6048e5?async function(e,t){try{var n=await async function(e,t){var n=await Ot(e),i=Pt(t.subscriptionOptions),i={method:"PATCH",headers:n,body:JSON.stringify(i)};let a;try{const r=await fetch(`${jt(e.appConfig)}/${t.token}`,i);a=await r.json()}catch(e){throw Dt.create("token-update-failed",{errorInfo:null==e?void 0:e.toString()})}if(a.error){i=a.error.message;throw Dt.create("token-update-failed",{errorInfo:i})}if(!a.token)throw Dt.create("token-update-no-token");return a.token}(e.firebaseDependencies,t),i=Object.assign(Object.assign({},t),{token:n,createTime:Date.now()});return await Tt(e.firebaseDependencies,i),n}catch(e){throw e}}(e,{token:c.token,createTime:Date.now(),subscriptionOptions:s}):c.token;try{await _t(e.firebaseDependencies,c.token)}catch(e){console.warn(e)}return At(e.firebaseDependencies,s)}return At(e.firebaseDependencies,s)}async function Mt(e){var t=await St(e.firebaseDependencies);t&&(await _t(e.firebaseDependencies,t.token),await async function(e){var t=Ct(e);const n=await It(),i=n.transaction(yt,"readwrite");await i.objectStore(yt).delete(t),await i.done}(e.firebaseDependencies));const n=await e.swRegistration.pushManager.getSubscription();return!n||n.unsubscribe()}async function At(e,t){var n={token:await async function(e,t){var n=await Ot(e),i=Pt(t),i={method:"POST",headers:n,body:JSON.stringify(i)};let a;try{const r=await fetch(jt(e.appConfig),i);a=await r.json()}catch(e){throw Dt.create("token-subscribe-failed",{errorInfo:null==e?void 0:e.toString()})}if(a.error){i=a.error.message;throw Dt.create("token-subscribe-failed",{errorInfo:i})}if(!a.token)throw Dt.create("token-subscribe-no-token");return a.token}(e,t),createTime:Date.now(),subscriptionOptions:t};return await Tt(e,n),n.token}async function Kt(e,t){var n=function(e,t){var n;const i={};e.from&&(i.project_number=e.from);e.fcmMessageId&&(i.message_id=e.fcmMessageId);i.instance_id=t,e.notification?i.message_type=L.DISPLAY_NOTIFICATION.toString():i.message_type=L.DATA_MESSAGE.toString();i.sdk_platform=lt.toString(),i.package_name=self.origin.replace(/(^\w+:|^)\/\//,""),e.collapse_key&&(i.collapse_key=e.collapse_key);i.event=ft.toString(),null!==(n=e.fcmOptions)&&void 0!==n&&n.analytics_label&&(i.analytics_label=null===(n=e.fcmOptions)||void 0===n?void 0:n.analytics_label);return i}(t,await e.firebaseDependencies.installations.getId());!function(e,t,n){const i={};i.event_time_ms=Math.floor(Date.now()).toString(),i.source_extension_json_proto3=JSON.stringify(t),n&&(i.compliance_data=function(e){var t={privacy_context:{prequest:{origin_associated_product_id:e}}};return t}(n));e.logEvents.push(i)}(e,n,t.productId)}function Nt(e,t){const n=[];for(let i=0;i<e.length;i++)n.push(e.charAt(i)),i<t.length&&n.push(t.charAt(i));return n.join("")}async function xt(e,t){var n=function({data:e}){if(!e)return null;try{return e.json()}catch(e){return null}}(e);if(n){t.deliveryMetricsExportedToBigQueryEnabled&&await Kt(t,n);var i,a,r,o,s=await Bt();if(s.some(e=>"visible"===e.visibilityState&&!e.url.startsWith("chrome-extension://")))return function(e,t){t.isFirebaseMessaging=!0,t.messageType=B.PUSH_RECEIVED;for(const n of e)n.postMessage(t)}(s,n);n.notification&&await function(e){var t=e["actions"],n=Notification["maxActions"];t&&n&&t.length>n&&console.warn(`This browser only supports ${n} actions. The remaining actions will not be displayed.`);return self.registration.showNotification(null!==(n=e.title)&&void 0!==n?n:"",e)}(function(e){const t=Object.assign({},e.notification);return t.data={[dt]:e},t}(n)),t&&t.onBackgroundMessageHandler&&(o={from:(i=n).from,collapseKey:i.collapse_key,messageId:i.fcmMessageId},s=o,(e=i).notification&&(s.notification={},(n=e.notification.title)&&(s.notification.title=n),(n=e.notification.body)&&(s.notification.body=n),(n=e.notification.image)&&(s.notification.image=n),(n=e.notification.icon)&&(s.notification.icon=n)),e=o,(s=i).data&&(e.data=s.data),s=o,((i=i).fcmOptions||null!==(a=i.notification)&&void 0!==a&&a.click_action)&&(s.fcmOptions={},(r=null!==(a=null===(a=i.fcmOptions)||void 0===a?void 0:a.link)&&void 0!==a?a:null===(r=i.notification)||void 0===r?void 0:r.click_action)&&(s.fcmOptions.link=r),(r=null===(r=i.fcmOptions)||void 0===r?void 0:r.analytics_label)&&(s.fcmOptions.analyticsLabel=r)),o=o,"function"==typeof t.onBackgroundMessageHandler?await t.onBackgroundMessageHandler(o):t.onBackgroundMessageHandler.next(o))}}async function Lt(e){const t=null===(r=null===(a=e.notification)||void 0===a?void 0:a.data)||void 0===r?void 0:r[dt];if(t&&!e.action){e.stopImmediatePropagation(),e.notification.close();var n=function(e){var t;var n=null!==(t=null===(t=e.fcmOptions)||void 0===t?void 0:t.link)&&void 0!==t?t:null===(n=e.notification)||void 0===n?void 0:n.click_action;if(n)return n;return function(e){return"object"==typeof e&&e&&pt in e}(e.data)?self.location.origin:null}(t);if(n){var i,a=new URL(n,self.location.href),r=new URL(self.location.origin);if(a.host===r.host){let e=await async function(e){var t=await Bt();for(const i of t){var n=new URL(i.url,self.location.href);if(e.host===n.host)return i}return null}(a);if(e?e=await e.focus():(e=await self.clients.openWindow(n),i=3e3,await new Promise(e=>{setTimeout(e,i)})),e)return t.messageType=B.NOTIFICATION_CLICKED,t.isFirebaseMessaging=!0,e.postMessage(t)}}}}function Bt(){return self.clients.matchAll({type:"window",includeUncontrolled:!0})}function $t(e){return Dt.create("missing-app-config-values",{valueName:e})}Nt("hts/frbslgigp.ogepscmv/ieo/eaylg","tp:/ieaeogn-agolai.o/1frlglgc/o"),Nt("AzSCbw63g1R0nCw85jG8","Iaya3yLKwmgvh7cF0q4");class Ft{constructor(e,t,n){this.deliveryMetricsExportedToBigQueryEnabled=!1,this.onBackgroundMessageHandler=null,this.onMessageHandler=null,this.logEvents=[],this.isLogServiceStarted=!1;var i=function(e){if(!e||!e.options)throw $t("App Configuration Object");if(!e.name)throw $t("App Name");var t=e["options"];for(const n of["projectId","apiKey","appId","messagingSenderId"])if(!t[n])throw $t(n);return{appName:e.name,projectId:t.projectId,apiKey:t.apiKey,appId:t.appId,senderId:t.messagingSenderId}}(e);this.firebaseDependencies={app:e,appConfig:i,installations:t,analyticsProvider:n}}_delete(){return Promise.resolve()}}const Ht=e=>{const t=new Ft(e.getProvider("app").getImmediate(),e.getProvider("installations-internal").getImmediate(),e.getProvider("analytics-internal"));return self.addEventListener("push",e=>{e.waitUntil(xt(e,t))}),self.addEventListener("pushsubscriptionchange",e=>{e.waitUntil(async function(e,t){var n;(n=e["newSubscription"])?(n=await St(t.firebaseDependencies),await Mt(t),t.vapidKey=null!==(n=null===(n=null==n?void 0:n.subscriptionOptions)||void 0===n?void 0:n.vapidKey)&&void 0!==n?n:ct,await Et(t)):await Mt(t)}(e,t))}),self.addEventListener("notificationclick",e=>{e.waitUntil(Lt(e))}),t};function Rt(e,t){return function(e,t){if(void 0!==self.document)throw Dt.create("only-available-in-sw");return e.onBackgroundMessageHandler=t,()=>{e.onBackgroundMessageHandler=null}}(e=r(e),t)}Gt._registerComponent(new c("messaging-sw",Ht,"PUBLIC"));class qt{constructor(e,t){this.app=e,this._delegate=t,this.app=e,this._delegate=t}async getToken(e){return async function(e,t){return Xe(e=r(e),t)}(this._delegate,e)}async deleteToken(){return ot(this._delegate)}onMessage(e){return st(this._delegate,e)}onBackgroundMessage(e){return Rt(this._delegate,e)}}const Vt=e=>self&&"ServiceWorkerGlobalScope"in self?new qt(e.getProvider("app-compat").getImmediate(),e.getProvider("messaging-sw").getImmediate()):new qt(e.getProvider("app-compat").getImmediate(),e.getProvider("messaging").getImmediate()),Wt={isSupported:function(){return self&&"ServiceWorkerGlobalScope"in self?n()&&"PushManager"in self&&"Notification"in self&&ServiceWorkerRegistration.prototype.hasOwnProperty("showNotification")&&PushSubscription.prototype.hasOwnProperty("getKey"):"undefined"!=typeof window&&n()&&!("undefined"==typeof navigator||!navigator.cookieEnabled)&&"serviceWorker"in navigator&&"PushManager"in window&&"Notification"in window&&"fetch"in window&&ServiceWorkerRegistration.prototype.hasOwnProperty("showNotification")&&PushSubscription.prototype.hasOwnProperty("getKey")}};t.default.INTERNAL.registerComponent(new c("messaging-compat",Vt,"PUBLIC").setServiceProps(Wt)),t.default.registerVersion("@firebase/messaging-compat","0.2.10")}).apply(this,arguments)}catch(e){throw console.error(e),new Error("Cannot instantiate firebase-messaging-compat.js - be sure to load firebase-app.js first.")}}); +//# sourceMappingURL=firebase-messaging-compat.js.map diff --git a/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging-sw.js b/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging-sw.js new file mode 100644 index 0000000000000000000000000000000000000000..55a9740a80ce67b997a85ffb941a4a8ebce19813 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging-sw.js @@ -0,0 +1,36 @@ +// Shim 'window' for Firebase compat SDK: service workers use 'self', not 'window' +self.window = self; + +importScripts("/firebase-app-compat.js"); +importScripts("/firebase-messaging-compat.js"); + +const ttsFirebaseConfig = { + apiKey: "AIzaSyAsudRlYNKctjvctfr4Vb84wWodUYSv5NQ", + authDomain: "ttsys-726b5.firebaseapp.com", + projectId: "ttsys-726b5", + storageBucket: "ttsys-726b5.firebasestorage.app", + messagingSenderId: "517093039925", + appId: "1:517093039925:web:08df7153a8f05ceb82b67c", + measurementId: "G-0TVHXFR6WB", + vapidKey: "BP8VcUO8HousJEE2s8yG7jBnL3tRfZOyK2cNMYX93SIdxefSytuOheHkfkjN8MVOu4GGhyKbb3OaAADoLlsZxqs" +}; + +firebase.initializeApp({ + apiKey: ttsFirebaseConfig.apiKey, + authDomain: ttsFirebaseConfig.authDomain, + projectId: ttsFirebaseConfig.projectId, + messagingSenderId: ttsFirebaseConfig.messagingSenderId, + appId: ttsFirebaseConfig.appId +}); + +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage((payload) => { + const title = payload?.notification?.title || "Taskify"; + const body = payload?.notification?.body || "You have a new notification."; + + self.registration.showNotification(title, { + body, + icon: "/favicon.png" + }); +}); diff --git a/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging.js b/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging.js new file mode 100644 index 0000000000000000000000000000000000000000..e0344ad575f7186961b5b6a7c32d392a5903a454 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/firebase-messaging.js @@ -0,0 +1,104 @@ +window.ttsFirebase = (() => { + let initialized = false; + + function getConfig() { + return window.ttsFirebaseConfig || {}; + } + + function ensureInitialized() { + if (initialized) { + return; + } + + if (typeof firebase === "undefined") { + throw new Error("Firebase SDK is not loaded."); + } + + const config = getConfig(); + if (!config.apiKey || !config.projectId || !config.messagingSenderId || !config.appId) { + throw new Error("Firebase web config is incomplete."); + } + + firebase.initializeApp({ + apiKey: config.apiKey, + authDomain: config.authDomain, + projectId: config.projectId, + messagingSenderId: config.messagingSenderId, + appId: config.appId + }); + + initialized = true; + } + + async function getRegistrationToken() { + if (!("Notification" in window) || !("serviceWorker" in navigator)) { + return null; + } + + const config = getConfig(); + if (!config.vapidKey) { + throw new Error("Firebase VAPID key is missing."); + } + + ensureInitialized(); + + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + return null; + } + + const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js"); + const messaging = firebase.messaging(); + const token = await messaging.getToken({ + vapidKey: config.vapidKey, + serviceWorkerRegistration: registration + }); + + return token || null; + } + + async function areNotificationsEnabled() { + if (!("Notification" in window) || !("serviceWorker" in navigator)) { + return false; + } + if (Notification.permission !== "granted") { + return false; + } + try { + const registration = await navigator.serviceWorker.getRegistration("/firebase-messaging-sw.js"); + if (!registration) { + return false; + } + const messaging = firebase.messaging(); + const config = getConfig(); + const token = await messaging.getToken({ + vapidKey: config.vapidKey, + serviceWorkerRegistration: registration + }); + return !!token; + } catch (e) { + return false; + } + } + + async function disableNotifications() { + if (!("Notification" in window) || !("serviceWorker" in navigator)) { + return; + } + try { + const registration = await navigator.serviceWorker.getRegistration("/firebase-messaging-sw.js"); + if (registration) { + const messaging = firebase.messaging(); + await messaging.deleteToken(); + } + } catch (e) { + console.error("Error during disableNotifications:", e); + } + } + + return { + getRegistrationToken, + areNotificationsEnabled, + disableNotifications + }; +})(); diff --git a/TaskTrackingSystem.WebApp/wwwroot/theme-toggle.js b/TaskTrackingSystem.WebApp/wwwroot/theme-toggle.js new file mode 100644 index 0000000000000000000000000000000000000000..51542f6cc7578e32506ea3994c093bd25e2087b2 --- /dev/null +++ b/TaskTrackingSystem.WebApp/wwwroot/theme-toggle.js @@ -0,0 +1,108 @@ +(function () { + const storageKey = "tts-theme"; + + function normalize(theme) { + return theme === "dark" || theme === "light" ? theme : "light"; + } + + function currentTheme() { + return normalize( + document.documentElement.dataset.theme || + (document.documentElement.classList.contains("dark") ? "dark" : "light") + ); + } + + function setTheme(theme) { + const normalized = normalize(theme); + const isDark = normalized === "dark"; + const root = document.documentElement; + + root.classList.toggle("dark", isDark); + root.dataset.theme = normalized; + + if (document.body) { + document.body.classList.toggle("dark", isDark); + document.body.dataset.theme = normalized; + } + + try { + localStorage.setItem(storageKey, normalized); + } catch { + } + + updateControls(); + return isDark; + } + + function updateControls() { + const isDark = currentTheme() === "dark"; + const title = isDark ? "Switch to light mode" : "Switch to dark mode"; + const iconName = isDark ? "sun" : "moon"; + + document.querySelectorAll("[data-theme-toggle]").forEach(button => { + button.setAttribute("aria-pressed", String(isDark)); + button.setAttribute("aria-label", title); + button.setAttribute("title", title); + + const icon = button.querySelector("[data-lucide]"); + if (icon) { + icon.setAttribute("data-lucide", iconName); + } + }); + + if (window.lucide) { + window.lucide.createIcons(); + } + } + + function initTheme() { + let storedTheme = currentTheme(); + try { + storedTheme = normalize(localStorage.getItem(storageKey) || storedTheme); + } catch { + } + + setTheme(storedTheme); + } + + window.themePreference = { + apply: setTheme, + init: initTheme, + toggle() { + return setTheme(currentTheme() === "dark" ? "light" : "dark"); + } + }; + + window.updateTaskifyThemeControls = updateControls; + window.toggleTaskifyThemeButton = function () { + window.themePreference.toggle(); + return false; + }; + + if (!window.__taskifyThemeToggleBound) { + window.__taskifyThemeToggleBound = true; + document.addEventListener("click", event => { + const target = event.target instanceof Element ? event.target : event.target?.parentElement; + const button = target?.closest("[data-theme-toggle]"); + if (!button) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + window.themePreference.toggle(); + }, true); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initTheme, { once: true }); + } else { + initTheme(); + } + + window.addEventListener("pageshow", updateControls); + + if (window.Blazor && typeof window.Blazor.addEventListener === "function") { + window.Blazor.addEventListener("enhancedload", updateControls); + } +})(); diff --git a/TaskTrackingSystem.sln b/TaskTrackingSystem.sln new file mode 100644 index 0000000000000000000000000000000000000000..c5e4288d798365d7560528d4188149b3a0b0ef8a --- /dev/null +++ b/TaskTrackingSystem.sln @@ -0,0 +1,60 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.7.11903.348 stable +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskTrackingSystem.WebApi", "TaskTrackingSystem.WebApi\TaskTrackingSystem.WebApi.csproj", "{45C2E476-C820-4461-A5A3-31AF6A189839}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskTrackingSystem.Database", "TaskTrackingSystem.Database\TaskTrackingSystem.Database.csproj", "{A2EA406D-35BA-4CF5-B0DD-E02DA0AE70E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskTrackingSystem.Shared", "TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj", "{927A5B66-1EF9-4230-A479-00312EB71DAE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskTrackingSystem.WebApp", "TaskTrackingSystem.WebApp\TaskTrackingSystem.WebApp.csproj", "{6C65AAE1-991F-4E42-AFB9-B8BBA9D80A88}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TranslationBackfill", "Tools\TranslationBackfill\TranslationBackfill.csproj", "{B7AA11E7-26A2-4C8E-A8C9-4DAB6C8F27F1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskTrackingSystem.Tests", "Tests\TaskTrackingSystem.Tests\TaskTrackingSystem.Tests.csproj", "{E2D8F45A-6FCA-4A6B-8B30-AD7140A6EE52}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{020D852B-0CDE-4177-87A2-6804A3043186}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {45C2E476-C820-4461-A5A3-31AF6A189839}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45C2E476-C820-4461-A5A3-31AF6A189839}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45C2E476-C820-4461-A5A3-31AF6A189839}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45C2E476-C820-4461-A5A3-31AF6A189839}.Release|Any CPU.Build.0 = Release|Any CPU + {A2EA406D-35BA-4CF5-B0DD-E02DA0AE70E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2EA406D-35BA-4CF5-B0DD-E02DA0AE70E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2EA406D-35BA-4CF5-B0DD-E02DA0AE70E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2EA406D-35BA-4CF5-B0DD-E02DA0AE70E8}.Release|Any CPU.Build.0 = Release|Any CPU + {927A5B66-1EF9-4230-A479-00312EB71DAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {927A5B66-1EF9-4230-A479-00312EB71DAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {927A5B66-1EF9-4230-A479-00312EB71DAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {927A5B66-1EF9-4230-A479-00312EB71DAE}.Release|Any CPU.Build.0 = Release|Any CPU + {6C65AAE1-991F-4E42-AFB9-B8BBA9D80A88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C65AAE1-991F-4E42-AFB9-B8BBA9D80A88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C65AAE1-991F-4E42-AFB9-B8BBA9D80A88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C65AAE1-991F-4E42-AFB9-B8BBA9D80A88}.Release|Any CPU.Build.0 = Release|Any CPU + {B7AA11E7-26A2-4C8E-A8C9-4DAB6C8F27F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7AA11E7-26A2-4C8E-A8C9-4DAB6C8F27F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7AA11E7-26A2-4C8E-A8C9-4DAB6C8F27F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7AA11E7-26A2-4C8E-A8C9-4DAB6C8F27F1}.Release|Any CPU.Build.0 = Release|Any CPU + {E2D8F45A-6FCA-4A6B-8B30-AD7140A6EE52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2D8F45A-6FCA-4A6B-8B30-AD7140A6EE52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2D8F45A-6FCA-4A6B-8B30-AD7140A6EE52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2D8F45A-6FCA-4A6B-8B30-AD7140A6EE52}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9F1264CF-54B7-4FC3-9F2B-2F8B40742C08} + EndGlobalSection +EndGlobal diff --git a/Tests/TaskTrackingSystem.Tests/BilingualPhaseTests.cs b/Tests/TaskTrackingSystem.Tests/BilingualPhaseTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..a1236f0aee51700623cc7a2cdb04cb30b899cae8 --- /dev/null +++ b/Tests/TaskTrackingSystem.Tests/BilingualPhaseTests.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using TaskTrackingSystem.Shared.Localization; +using TaskTrackingSystem.Shared.Models.Project; +using TaskTrackingSystem.Shared.Models.User; +using Xunit; + +namespace TaskTrackingSystem.Tests; + +public sealed class BilingualPhaseTests +{ + [Fact] + public void LanguageDetector_ClassifiesBurmeseEnglishMixedAndEmpty() + { + var detector = new LanguageDetectionService(); + + Assert.Equal(DetectedContentLanguage.Empty, detector.Detect(" ").Language); + Assert.Equal(DetectedContentLanguage.Burmese, detector.Detect("စီမံကိန်း").Language); + Assert.Equal(DetectedContentLanguage.English, detector.Detect("Project Dashboard").Language); + Assert.Equal(DetectedContentLanguage.Mixed, detector.Detect("Dashboard စီမံကိန်း").Language); + } + + [Fact] + public void ProjectValidation_AllowsEitherLanguageButRejectsNeither() + { + Assert.Empty(Validate(new CreateProjectDto { Name = "English" })); + Assert.Empty(Validate(new CreateProjectDto { NameMy = "မြန်မာ" })); + Assert.NotEmpty(Validate(new CreateProjectDto())); + } + + [Fact] + public void UserValidation_RequiresACompleteNameInOneLanguage() + { + Assert.DoesNotContain(Validate(User(firstName: "A", lastName: "User")), result => result.ErrorMessage?.Contains("complete name", StringComparison.OrdinalIgnoreCase) == true); + Assert.DoesNotContain(Validate(User(firstNameMy: "အ", lastNameMy: "သုံး")), result => result.ErrorMessage?.Contains("complete name", StringComparison.OrdinalIgnoreCase) == true); + Assert.Contains(Validate(User(firstName: "A", firstNameMy: "အ")), result => result.ErrorMessage?.Contains("complete name", StringComparison.OrdinalIgnoreCase) == true); + } + + [Fact] + public async Task UnconfiguredTranslationDoesNotFakeText() + { + var result = await new NoOpContentTranslationService().TranslateAsync("Project", "en", "my"); + + Assert.False(result.Success); + Assert.Null(result.TranslatedText); + Assert.False(result.WasGenerated); + Assert.Equal("none", result.Provider); + } + + private static List<ValidationResult> Validate(object model) + { + var results = new List<ValidationResult>(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } + + private static CreateUserDto User(string? firstName = null, string? lastName = null, string? firstNameMy = null, string? lastNameMy = null) => new() + { + Username = "testuser", + Email = "test@example.com", + Password = "Password1!", + RoleId = 1, + FirstName = firstName ?? string.Empty, + LastName = lastName ?? string.Empty, + FirstNameMy = firstNameMy, + LastNameMy = lastNameMy + }; +} diff --git a/Tests/TaskTrackingSystem.Tests/OpenRouterContentTranslationServiceTests.cs b/Tests/TaskTrackingSystem.Tests/OpenRouterContentTranslationServiceTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..0e56a569b9721da4c65e5cc4b4f3578f3f4c441c --- /dev/null +++ b/Tests/TaskTrackingSystem.Tests/OpenRouterContentTranslationServiceTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using TaskTrackingSystem.Shared.Localization; +using TaskTrackingSystem.WebApi.Features.Translation; +using Xunit; + +namespace TaskTrackingSystem.Tests; + +public sealed class OpenRouterContentTranslationServiceTests +{ + private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions"; + + [Fact] + public async Task TranslateAsync_SendsOpenRouterRequestAndParsesText() + { + var handler = new StubHandler(HttpStatusCode.OK, """{"choices":[{"message":{"content":" \"Hello\" "}}]}"""); + var result = await CreateService(handler, "secret-test-key").TranslateAsync("မင်္ဂလာပါ", "Burmese", "English"); + + Assert.True(result.Success); + Assert.Equal("Hello", result.TranslatedText); + Assert.Equal("OpenRouter", result.Provider); + Assert.Equal("Bearer secret-test-key", handler.Request!.Headers.Authorization?.ToString()); + Assert.Equal("https://huggingface.co", handler.Request.Headers.GetValues("HTTP-Referer").Single()); + Assert.Equal("TaskTrackingSystem", handler.Request.Headers.GetValues("X-Title").Single()); + + using var body = JsonDocument.Parse(handler.RequestBody!); + var root = body.RootElement; + Assert.Equal("openrouter/free", root.GetProperty("model").GetString()); + Assert.Equal(0.2, root.GetProperty("temperature").GetDouble()); + Assert.Equal("system", root.GetProperty("messages")[0].GetProperty("role").GetString()); + Assert.Equal("You are a professional translator. Return ONLY the translated text.", root.GetProperty("messages")[0].GetProperty("content").GetString()); + Assert.Contains("Translate from Burmese to English: မင်္ဂလာပါ", root.GetProperty("messages")[1].GetProperty("content").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task TransliterateNameAsync_UsesNamePrompt() + { + var handler = new StubHandler(HttpStatusCode.OK, """{"choices":[{"message":{"content":"John Smith"}}]}"""); + var result = await CreateService(handler, "key").TransliterateNameAsync("ဂျွန် စမစ်", "Burmese", "English"); + + Assert.True(result.Success); + Assert.Equal("John Smith", result.TranslatedText); + using var body = JsonDocument.Parse(handler.RequestBody!); + var systemText = body.RootElement.GetProperty("messages")[0].GetProperty("content").GetString(); + var userText = body.RootElement.GetProperty("messages")[1].GetProperty("content").GetString(); + Assert.Contains("name transliterator", systemText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Transliterate the personal name", userText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task MissingEndpointOrKey_ReturnsControlledFailure() + { + var endpointMissing = CreateService(new StubHandler(HttpStatusCode.OK, "{}"), "key", endpoint: ""); + var keyMissing = CreateService(new StubHandler(HttpStatusCode.OK, "{}"), ""); + + var endpointResult = await endpointMissing.TranslateAsync("Project", "English", "Burmese"); + var keyResult = await keyMissing.TranslateAsync("Project", "English", "Burmese"); + + Assert.False(endpointResult.Success); + Assert.False(keyResult.Success); + Assert.Equal("Translation provider is not configured.", endpointResult.ErrorMessage); + Assert.Equal("OpenRouter", endpointResult.Provider); + } + + [Theory] + [InlineData(400)] + [InlineData(401)] + [InlineData(429)] + [InlineData(500)] + public async Task HttpErrors_ReturnControlledFailure(int statusCode) + { + var handler = new StubHandler((HttpStatusCode)statusCode, """{"error":{"message":"provider detail"}}"""); + var result = await CreateService(handler, "secret-test-key").TranslateAsync("Project", "English", "Burmese"); + + Assert.False(result.Success); + Assert.Equal("OpenRouter", result.Provider); + Assert.Null(result.TranslatedText); + Assert.Contains(statusCode.ToString(), result.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("secret-test-key", result.ErrorMessage, StringComparison.Ordinal); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"choices\":[]}")] + [InlineData("{\"choices\":[{\"message\":{}}]}")] + [InlineData("not-json")] + [InlineData("{\"choices\":[{\"message\":{\"content\":\" \"}}]}")] + public async Task EmptyOrInvalidResponses_ReturnControlledFailure(string responseBody) + { + var handler = new StubHandler(HttpStatusCode.OK, responseBody); + var result = await CreateService(handler, "key").TranslateAsync("Project", "English", "Burmese"); + + Assert.False(result.Success); + Assert.Equal("OpenRouter", result.Provider); + Assert.Null(result.TranslatedText); + } + + [Fact] + public async Task Cancellation_IsPropagated() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var service = CreateService(new StubHandler(HttpStatusCode.OK, "{}"), "key"); + + await Assert.ThrowsAnyAsync<OperationCanceledException>(() => + service.TranslateAsync("Project", "English", "Burmese", cancellation.Token)); + } + + private static OpenRouterContentTranslationService CreateService(StubHandler handler, string apiKey, string endpoint = Endpoint) + { + var values = new Dictionary<string, string?> + { + ["Translation:Endpoint"] = endpoint, + ["Translation:ApiKey"] = apiKey, + ["Translation:Model"] = "openrouter/free" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + return new OpenRouterContentTranslationService( + new StubHttpClientFactory(handler), + configuration, + NullLogger<OpenRouterContentTranslationService>.Instance); + } + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly HttpClient client; + + public StubHttpClientFactory(HttpMessageHandler handler) + { + client = new HttpClient(handler); + } + + public HttpClient CreateClient(string name) => client; + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly HttpStatusCode statusCode; + private readonly string responseBody; + + public StubHandler(HttpStatusCode statusCode, string responseBody) + { + this.statusCode = statusCode; + this.responseBody = responseBody; + } + + public HttpRequestMessage? Request { get; private set; } + public string? RequestBody { get; private set; } + + protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + RequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/Tests/TaskTrackingSystem.Tests/TaskTrackingSystem.Tests.csproj b/Tests/TaskTrackingSystem.Tests/TaskTrackingSystem.Tests.csproj new file mode 100644 index 0000000000000000000000000000000000000000..d3930fdb7c5465952c6b13c935e329bb9dd6d407 --- /dev/null +++ b/Tests/TaskTrackingSystem.Tests/TaskTrackingSystem.Tests.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <Compile Remove="artifacts\**" /> + <None Remove="artifacts\**" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.3" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> + <ProjectReference Include="..\..\TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj" /> + <Compile Include="..\..\TaskTrackingSystem.WebApi\Features\Translation\OpenRouterContentTranslationService.cs" Link="Features\Translation\OpenRouterContentTranslationService.cs" /> + </ItemGroup> +</Project> diff --git a/Tools/TranslationBackfill/Program.cs b/Tools/TranslationBackfill/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..066803ffe440e73ee147b85c3a8060681d88f827 --- /dev/null +++ b/Tools/TranslationBackfill/Program.cs @@ -0,0 +1,216 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Npgsql; +using TaskTrackingSystem.Shared.Localization; + +var options = BackfillOptions.Parse(args); +if (options.ShowHelp) +{ + BackfillOptions.PrintHelp(); + return 0; +} + +var connectionString = Environment.GetEnvironmentVariable("SUPABASE_CONNECTION_STRING") + ?? Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection"); +if (string.IsNullOrWhiteSpace(connectionString)) +{ + Console.Error.WriteLine("Missing SUPABASE_CONNECTION_STRING or ConnectionStrings__DefaultConnection."); + return 2; +} + +if (!options.Apply) +{ + Console.WriteLine("Dry run mode: no database updates will be written. Use --apply to persist translations."); +} + +var provider = new BackfillTranslationProvider(); +await using var connection = new NpgsqlConnection(connectionString); +await connection.OpenAsync(options.CancellationToken); + +var targets = BackfillTarget.Create(options); +var summary = new BackfillSummary(); + +foreach (var target in targets) +{ + var rows = await ReadRowsAsync(connection, target, options); + foreach (var batch in rows.Chunk(options.BatchSize)) + { + foreach (var row in batch) + { + foreach (var field in target.Fields) + { + if (string.IsNullOrWhiteSpace(row.Values[field.Source]) || !string.IsNullOrWhiteSpace(row.Values[field.Target])) + { + continue; + } + + summary.Inspected++; + var source = row.Values[field.Source]!; + var result = await provider.TranslateAsync( + source, + field.IsName ? "my" : "en", + field.IsName ? "en" : "my", + field.IsName, + options.CancellationToken); + + if (!result.Success || string.IsNullOrWhiteSpace(result.TranslatedText)) + { + summary.Failed++; + Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: failed provider={result.Provider} error={result.ErrorMessage}"); + continue; + } + + if (!options.Apply) + { + summary.WouldUpdate++; + Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: would update provider={result.Provider}"); + continue; + } + + try + { + var updated = await UpdateMissingFieldAsync(connection, target, row.Id, field, result.TranslatedText!, options.CancellationToken); + if (updated) + { + summary.Updated++; + Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: updated provider={result.Provider}"); + } + else + { + summary.Skipped++; + Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: skipped because target was filled concurrently"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + summary.Failed++; + Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: failed database update error={ex.Message}"); + } + } + } + } +} + +Console.WriteLine($"Summary: inspected={summary.Inspected}; wouldUpdate={summary.WouldUpdate}; updated={summary.Updated}; skipped={summary.Skipped}; failed={summary.Failed}; mode={(options.Apply ? "apply" : "dry-run")}"); +return 0; + +static async Task<List<BackfillRow>> ReadRowsAsync(NpgsqlConnection connection, BackfillTarget target, BackfillOptions options) +{ + var missing = string.Join(" OR ", target.Fields.Select(field => $"(\"{field.Target}\" IS NULL OR btrim(\"{field.Target}\") = '')")); + var sql = $"SELECT \"Id\", {string.Join(", ", target.Fields.SelectMany(field => new[] { $"\"{field.Source}\"", $"\"{field.Target}\"" }).Distinct())} FROM \"{target.Table}\" WHERE ({string.Join(" OR ", target.Fields.Select(field => $"\"{field.Source}\" IS NOT NULL AND btrim(\"{field.Source}\") <> ''"))}) AND ({missing})"; + if (options.Id.HasValue) sql += " AND \"Id\" = @id"; + sql += " ORDER BY \"Id\" LIMIT @limit"; + + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("limit", options.Limit ?? int.MaxValue); + if (options.Id.HasValue) command.Parameters.AddWithValue("id", options.Id.Value); + + var rows = new List<BackfillRow>(); + await using var reader = await command.ExecuteReaderAsync(options.CancellationToken); + while (await reader.ReadAsync(options.CancellationToken)) + { + var values = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase); + for (var index = 1; index < reader.FieldCount; index++) + { + values[reader.GetName(index)] = reader.IsDBNull(index) ? null : reader.GetString(index); + } + rows.Add(new(reader.GetInt64(0), values)); + } + return rows; +} + +static async Task<bool> UpdateMissingFieldAsync(NpgsqlConnection connection, BackfillTarget target, long id, BackfillField field, string translatedText, CancellationToken cancellationToken) +{ + var sql = $"UPDATE \"{target.Table}\" SET \"{field.Target}\" = @value WHERE \"Id\" = @id AND (\"{field.Target}\" IS NULL OR btrim(\"{field.Target}\") = '')"; + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("value", translatedText); + command.Parameters.AddWithValue("id", id); + return await command.ExecuteNonQueryAsync(cancellationToken) == 1; +} + +internal sealed record BackfillTarget(string Table, IReadOnlyList<BackfillField> Fields) +{ + public static IReadOnlyList<BackfillTarget> Create(BackfillOptions options) + { + var all = new List<BackfillTarget> + { + new("Projects", [new("Name", "NameMy"), new("Description", "DescriptionMy")]), + new("Tasks", [new("Title", "TitleMy"), new("Description", "DescriptionMy")]), + new("Issues", [new("Title", "TitleMy"), new("Description", "DescriptionMy"), new("DelayReason", "DelayReasonMy"), new("BlockedBy", "BlockedByMy")]), + new("Users", [new("FirstName", "FirstNameMy", true), new("LastName", "LastNameMy", true)]), + new("Roles", [new("Name", "NameMy"), new("Description", "DescriptionMy")]), + new("notifications", [new("Title", "TitleMy"), new("Body", "BodyMy")]) + }; + if (options.IncludeComments) all.Add(new("Comments", [new("Message", "MessageMy")])); + return options.Table is null ? all : all.Where(target => string.Equals(target.Table, options.Table, StringComparison.OrdinalIgnoreCase)).ToArray(); + } +} + +internal sealed record BackfillField(string Source, string Target, bool IsName = false); +internal sealed record BackfillRow(long Id, Dictionary<string, string?> Values); +internal sealed class BackfillSummary { public int Inspected; public int WouldUpdate; public int Updated; public int Skipped; public int Failed; } + +internal sealed class BackfillOptions +{ + public bool Apply { get; private set; } + public bool IncludeComments { get; private set; } + public bool ShowHelp { get; private set; } + public string? Table { get; private set; } + public int? Limit { get; private set; } + public int BatchSize { get; private set; } = 20; + public long? Id { get; private set; } + public CancellationToken CancellationToken => cancellationSource.Token; + private readonly CancellationTokenSource cancellationSource = new(); + + public static BackfillOptions Parse(string[] args) + { + var options = new BackfillOptions(); + Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; options.cancellationSource.Cancel(); }; + for (var i = 0; i < args.Length; i++) + { + switch (args[i].ToLowerInvariant()) + { + case "--apply": options.Apply = true; break; + case "--dry-run": options.Apply = false; break; + case "--include-comments": options.IncludeComments = true; break; + case "--table" when i + 1 < args.Length: options.Table = args[++i]; break; + case "--limit" when i + 1 < args.Length && int.TryParse(args[++i], out var limit): options.Limit = Math.Max(1, limit); break; + case "--batch-size" when i + 1 < args.Length && int.TryParse(args[++i], out var batchSize): options.BatchSize = Math.Max(1, batchSize); break; + case "--id" when i + 1 < args.Length && long.TryParse(args[++i], out var id): options.Id = id; break; + case "--help" or "-h": options.ShowHelp = true; break; + } + } + return options; + } + + public static void PrintHelp() => Console.WriteLine("Usage: dotnet run --project Tools/TranslationBackfill -- --dry-run|--apply [--table Projects] [--batch-size 20] [--limit 100] [--id 123] [--include-comments]"); +} + +internal sealed class BackfillTranslationProvider +{ + private readonly HttpClient client = new(); + private readonly string? endpoint = Environment.GetEnvironmentVariable("TRANSLATION_API_URL"); + private readonly string? apiKey = Environment.GetEnvironmentVariable("TRANSLATION_API_KEY"); + private readonly string provider = Environment.GetEnvironmentVariable("TRANSLATION_PROVIDER") ?? "configured-http"; + + public async Task<TranslationResult> TranslateAsync(string sourceText, string sourceLanguage, string targetLanguage, bool isName, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(endpoint)) return TranslationResult.NotConfigured(); + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + if (!string.IsNullOrWhiteSpace(apiKey)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Content = JsonContent.Create(new { text = sourceText, sourceLanguage, targetLanguage, mode = isName ? "transliteration" : "translation", instruction = isName ? "Return the name only; transliterate pronunciation, do not translate meaning." : "Return translated text only; preserve names, codes, IDs, dates, numbers, placeholders, URLs, and appropriate technical terms." }); + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) return new(false, null, $"Provider returned {(int)response.StatusCode}.", provider, false); + try + { + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + var text = root.TryGetProperty("translatedText", out var translated) ? translated.GetString() : root.TryGetProperty("text", out var plain) ? plain.GetString() : root.TryGetProperty("translation", out var alternate) ? alternate.GetString() : null; + return string.IsNullOrWhiteSpace(text) ? new(false, null, "Provider returned no translated text.", provider, false) : TranslationResult.Generated(text.Trim(), provider); + } + catch (JsonException) { return new(false, null, "Provider returned invalid JSON.", provider, false); } + } +} diff --git a/Tools/TranslationBackfill/TranslationBackfill.csproj b/Tools/TranslationBackfill/TranslationBackfill.csproj new file mode 100644 index 0000000000000000000000000000000000000000..8919aaca389ad469ac5e8e98cbaf3588dfe72cb6 --- /dev/null +++ b/Tools/TranslationBackfill/TranslationBackfill.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <Compile Remove="artifacts\**" /> + <None Remove="artifacts\**" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Npgsql" Version="9.0.3" /> + <ProjectReference Include="..\..\TaskTrackingSystem.Shared\TaskTrackingSystem.Shared.csproj" /> + </ItemGroup> +</Project> diff --git a/database/migrations/2026_add_bilingual_columns.sql b/database/migrations/2026_add_bilingual_columns.sql new file mode 100644 index 0000000000000000000000000000000000000000..d98edad29d69cb8274d9befed33bb80de07db76c --- /dev/null +++ b/database/migrations/2026_add_bilingual_columns.sql @@ -0,0 +1,21 @@ +BEGIN; + +ALTER TABLE "Menus" ADD COLUMN IF NOT EXISTS "MenuNameMy" text; +ALTER TABLE "Permissions" ADD COLUMN IF NOT EXISTS "ActionNameMy" text; +ALTER TABLE "Roles" ADD COLUMN IF NOT EXISTS "NameMy" text; +ALTER TABLE "Roles" ADD COLUMN IF NOT EXISTS "DescriptionMy" text; +ALTER TABLE "Users" ADD COLUMN IF NOT EXISTS "FirstNameMy" text; +ALTER TABLE "Users" ADD COLUMN IF NOT EXISTS "LastNameMy" text; +ALTER TABLE "Projects" ADD COLUMN IF NOT EXISTS "NameMy" text; +ALTER TABLE "Projects" ADD COLUMN IF NOT EXISTS "DescriptionMy" text; +ALTER TABLE "Tasks" ADD COLUMN IF NOT EXISTS "TitleMy" text; +ALTER TABLE "Tasks" ADD COLUMN IF NOT EXISTS "DescriptionMy" text; +ALTER TABLE "Issues" ADD COLUMN IF NOT EXISTS "TitleMy" text; +ALTER TABLE "Issues" ADD COLUMN IF NOT EXISTS "DescriptionMy" text; +ALTER TABLE "Issues" ADD COLUMN IF NOT EXISTS "DelayReasonMy" text; +ALTER TABLE "Issues" ADD COLUMN IF NOT EXISTS "BlockedByMy" text; +ALTER TABLE "Comments" ADD COLUMN IF NOT EXISTS "MessageMy" text; +ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "TitleMy" text; +ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "BodyMy" text; + +COMMIT; diff --git a/database/migrations/2026_seed_burmese_labels.sql b/database/migrations/2026_seed_burmese_labels.sql new file mode 100644 index 0000000000000000000000000000000000000000..5550a102a48f7f951c3ae52fa16e0a30fef07277 --- /dev/null +++ b/database/migrations/2026_seed_burmese_labels.sql @@ -0,0 +1,39 @@ +BEGIN; + +UPDATE "Menus" SET "MenuNameMy" = 'ဒက်ရှ်ဘုတ်' WHERE "MenuCode" IN ('DASHBOARD', 'DASHBOARD_ADMIN', 'DASHBOARD_MANAGER', 'DASHBOARD_EMPLOYEE'); +UPDATE "Menus" SET "MenuNameMy" = 'စီမံကိန်းများ' WHERE "MenuCode" = 'PROJECTS'; +UPDATE "Menus" SET "MenuNameMy" = 'စီမံကိန်းစာရင်း' WHERE "MenuCode" = 'PROJECTS_LIST'; +UPDATE "Menus" SET "MenuNameMy" = 'စီမံကိန်း ခွဲဝေခြင်း' WHERE "MenuCode" = 'PROJECTS_ASSIGN'; +UPDATE "Menus" SET "MenuNameMy" = 'လုပ်ငန်းများ' WHERE "MenuCode" = 'TASKS'; +UPDATE "Menus" SET "MenuNameMy" = 'လုပ်ငန်းစာရင်း' WHERE "MenuCode" = 'TASKS_LIST'; +UPDATE "Menus" SET "MenuNameMy" = 'ကန်ဘန်းဘုတ်' WHERE "MenuCode" = 'TASKS_BOARD'; +UPDATE "Menus" SET "MenuNameMy" = 'လုပ်ငန်း ခွဲဝေခြင်း' WHERE "MenuCode" = 'TASKS_ASSIGN'; +UPDATE "Menus" SET "MenuNameMy" = 'လုပ်ငန်း နောက်ခံစာရင်း' WHERE "MenuCode" = 'TASKS_BACKLOG'; +UPDATE "Menus" SET "MenuNameMy" = 'အစီရင်ခံစာများ' WHERE "MenuCode" = 'REPORTS'; +UPDATE "Menus" SET "MenuNameMy" = 'လုပ်ငန်း အစီရင်ခံစာ' WHERE "MenuCode" = 'REPORTS_TASKS'; +UPDATE "Menus" SET "MenuNameMy" = 'အချိန်မှတ်တမ်း' WHERE "MenuCode" = 'REPORTS_TIMESHEET'; +UPDATE "Menus" SET "MenuNameMy" = 'နောက်ကျ လုပ်ငန်းများ' WHERE "MenuCode" = 'REPORTS_OVERDUE'; +UPDATE "Menus" SET "MenuNameMy" = 'ဝန်ထမ်း အစီရင်ခံစာ' WHERE "MenuCode" = 'REPORTS_EMPLOYEES'; +UPDATE "Menus" SET "MenuNameMy" = 'စီမံကိန်း တိုးတက်မှု' WHERE "MenuCode" = 'REPORTS_PROJECTS'; +UPDATE "Menus" SET "MenuNameMy" = 'အသုံးပြုသူများ' WHERE "MenuCode" = 'USERS'; +UPDATE "Menus" SET "MenuNameMy" = 'အခန်းကဏ္ဍများ' WHERE "MenuCode" = 'ROLES'; +UPDATE "Menus" SET "MenuNameMy" = 'အခန်းကဏ္ဍ အပြင်အဆင်များ' WHERE "MenuCode" = 'DASHBOARD_WIDGETS'; +UPDATE "Menus" SET "MenuNameMy" = 'ပြဿနာ ထည့်ရန်' WHERE "MenuCode" = 'ISSUES_ADD'; +UPDATE "Menus" SET "MenuNameMy" = 'ပြဿနာ စာရင်း' WHERE "MenuCode" = 'ISSUES_LIST'; +UPDATE "Menus" SET "MenuNameMy" = 'စနစ်မှတ်တမ်းများ' WHERE "MenuCode" = 'AUDIT_LOGS'; +UPDATE "Menus" SET "MenuNameMy" = 'ပြဿနာ အစီရင်ခံစာ' WHERE "MenuCode" = 'REPORTS_ISSUES'; + +UPDATE "Permissions" SET "ActionNameMy" = 'စာရင်းကြည့်ရန်' +WHERE "PermissionCode" IN ('Projects_List', 'Tasks_List', 'Users_List', 'Roles_List', 'Issues_List', 'AuditLogs_List'); +UPDATE "Permissions" SET "ActionNameMy" = 'ဖန်တီးရန်' +WHERE "PermissionCode" IN ('Projects_Create', 'Tasks_Create', 'Users_Create', 'Roles_Create', 'Issues_Create'); +UPDATE "Permissions" SET "ActionNameMy" = 'ပြင်ဆင်ရန်' +WHERE "PermissionCode" IN ('Projects_Update', 'Tasks_Update', 'Users_Update', 'Roles_Update', 'Issues_Update'); +UPDATE "Permissions" SET "ActionNameMy" = 'ဖျက်ရန်' +WHERE "PermissionCode" IN ('Projects_Delete', 'Tasks_Delete', 'Users_Delete', 'Roles_Delete', 'Issues_Delete'); + +UPDATE "Roles" SET "NameMy" = 'စီမံခန့်ခွဲသူ', "DescriptionMy" = 'စနစ်အပြည့်အဝ အသုံးပြုခွင့်' WHERE "Name" = 'Admin'; +UPDATE "Roles" SET "NameMy" = 'မန်နေဂျာ', "DescriptionMy" = 'စီမံကိန်းအလိုက် စီမံခန့်ခွဲသူ' WHERE "Name" = 'Manager'; +UPDATE "Roles" SET "NameMy" = 'ဝန်ထမ်း', "DescriptionMy" = 'လုပ်ငန်းအဆင့် အသုံးပြုခွင့်' WHERE "Name" = 'Employee'; + +COMMIT; diff --git a/database/migrations/2026_update_burmese_display_labels.sql b/database/migrations/2026_update_burmese_display_labels.sql new file mode 100644 index 0000000000000000000000000000000000000000..787b652cf8e382e2116f883dc6ebe9556a790de3 --- /dev/null +++ b/database/migrations/2026_update_burmese_display_labels.sql @@ -0,0 +1,20 @@ +BEGIN; + +-- Localized display labels only. Stable menu codes and English values remain unchanged. +UPDATE "Menus" +SET "MenuNameMy" = 'ပင်မစာမျက်နှာ' +WHERE "MenuCode" IN ('DASHBOARD', 'DASHBOARD_ADMIN', 'DASHBOARD_MANAGER', 'DASHBOARD_EMPLOYEE'); + +UPDATE "Menus" +SET "MenuNameMy" = 'အသုံးပြုသူ' +WHERE "MenuCode" = 'USERS'; + +-- The default admin account is displayed as a Burmese name in Burmese UI. +UPDATE "Users" +SET "FirstNameMy" = 'စနစ်', + "LastNameMy" = 'အုပ်ချုပ်သူ' +WHERE "Username" = 'admin' + AND "FirstName" = 'System' + AND "LastName" = 'Admin'; + +COMMIT; diff --git a/database/scripts/backfill_burmese_missing.sql b/database/scripts/backfill_burmese_missing.sql new file mode 100644 index 0000000000000000000000000000000000000000..a798661a4694fa8520d6ed2700021b8d311e8c31 --- /dev/null +++ b/database/scripts/backfill_burmese_missing.sql @@ -0,0 +1,663 @@ +-- Burmese backfill generated from the supplied Supabase CSV exports. +-- Review before applying to production. This script was not executed. + +BEGIN; + +-- Projects: NameMy +UPDATE "Projects" +SET "NameMy" = v."Value" +FROM ( + VALUES + (1, 'ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'), -- Source record ID: 1 + (2, 'မန္တလေး ကုန်ပစ္စည်းစာရင်း စနစ်'), -- Source record ID: 2 + (3, 'နေပြည်တော် လူ့စွမ်းအားအရင်းအမြစ် စီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 3 + (4, 'ပဲခူး ဝယ်ယူရေး စီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 4 + (5, 'တောင်ကြီး အကူအညီပေးရေးစနစ်'), -- Source record ID: 5 + (6, 'မော်လမြိုင် ထောက်ပံ့ရေးကွင်းဆက် စီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 6 + (7, 'ပုသိမ် ပင်လယ်စာ ရောင်းဝယ်ရေးပေါ်တယ်'), -- Source record ID: 7 + (8, 'မုံရွာ ငွေတောင်းခံမှုစနစ်'), -- Source record ID: 8 + (9, 'စစ်တွေ ဆိပ်ကမ်း ဒက်ရှ်ဘုတ်'), -- Source record ID: 9 + (10, 'ပြင်ဦးလွင် စိုက်ပျိုးရေးနည်းပညာစနစ်'), -- Source record ID: 10 + (11, 'လားရှိုး လက်လီရောင်းဝယ်ရေးစနစ်'), -- Source record ID: 11 + (12, 'ထားဝယ် သဘာဝခရီးသွား အက်ပ်'), -- Source record ID: 12 + (13, 'ဘားအံ လုပ်ငန်းသုံး ERP စနစ်'), -- Source record ID: 13 + (14, 'မကွေး နေရောင်ခြည်စွမ်းအင် ကွန်ရက်ခြေရာခံစနစ်'), -- Source record ID: 14 + (15, 'မြစ်ကြီးနား ပညာရေးပေါ်တယ်'), -- Source record ID: 15 + (16, 'ရွှေဘို ဆန်စက်စီမံခန့်ခွဲမှုဆော့ဖ်ဝဲ'), -- Source record ID: 16 + (17, 'ကော့သောင်း ရေကြောင်းဆိုင်ရာ အချက်အလက်စနစ်'), -- Source record ID: 17 + (18, 'ကလေး ကျန်းမာရေးခြေရာခံစနစ်'), -- Source record ID: 18 + (19, 'ကျောက်မဲ လက်ဖက် ထောက်ပံ့ရေးကွင်းဆက်စနစ်'), -- Source record ID: 19 + (20, 'မိတ္ထီလာ အထည်အလိပ် ကုန်ပစ္စည်းစာရင်းစနစ်'), -- Source record ID: 20 + (21, 'စမ်းသပ်မှု'), -- Source record ID: 21 + (22, 'အဝတ်အထည်'), -- Source record ID: 22 + (23, 'ရန်ကုန် လက်လီရောင်းဝယ်ရေး အဆင့်မြှင့်စီမံကိန်း'), -- Source record ID: 23 + (24, 'မန္တလေး ဝန်ဆောင်မှုပေါ်တယ်'), -- Source record ID: 24 + (25, 'နေပြည်တော် လစာစီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 25 + (26, 'ပဲခူး ရောင်းချသူစီမံခန့်ခွဲမှုဘုတ်'), -- Source record ID: 26 + (27, 'တောင်ကြီး ပို့ဆောင်ရေးခြေရာခံစနစ်'), -- Source record ID: 27 + (28, 'မော်လမြိုင် ကွင်းဆင်း အက်ပ်'), -- Source record ID: 28 + (29, 'ပုသိမ် အရောင်းဒက်ရှ်ဘုတ်'), -- Source record ID: 29 + (30, 'မုံရွာ အာမခံတောင်းဆိုမှုစနစ်'), -- Source record ID: 30 + (31, 'စစ်တွေ ထောက်ပံ့ပို့ဆောင်ရေး ထိန်းချုပ်စနစ်'), -- Source record ID: 31 + (32, 'ပြင်ဦးလွင် ငွေတောင်းခံမှု အဆင့်မြှင့်စနစ်'), -- Source record ID: 32 + (33, 'လားရှိုး ဖောက်သည်စောင့်ရှောက်ရေးစနစ်'), -- Source record ID: 33 + (34, 'ထားဝယ် လုပ်ငန်းဆောင်ရွက်မှု စီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 34 + (35, 'Codex တိုက်ရိုက်ဖန်တီးမှု စမ်းသပ်ခြင်း'), -- Source record ID: 35 + (36, 'Codex HF စီမံကိန်းရက်စွဲ စမ်းသပ်ခြင်း'), -- Source record ID: 36 + (37, 'စမ်းသပ်မှု'), -- Source record ID: 37 + (38, 'Codex စစ်ဆေးရေး စီမံကိန်း 262588226'), -- Source record ID: 38 + (39, 'ယ​နေ့​ငွေ​စျေး'), -- Source record ID: 39 + (40, 'စမ်းသပ်နေသည်'), -- Source record ID: 40 + (41, 'ဆိုင်ကယ်ရောင်းဝယ်ရေး') -- Source record ID: 41 +) AS v("Id", "Value") +WHERE "Projects"."Id" = v."Id" + AND ("NameMy" IS NULL OR btrim("NameMy") = ''); + +-- Projects: DescriptionMy +UPDATE "Projects" +SET "DescriptionMy" = v."Value" +FROM ( + VALUES + (1, 'ရန်ကုန်ရှိ ရုံးခွဲများအတွက် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'), -- Source record ID: 1 + (2, 'မန္တလေးအဖွဲ့အတွက် ဂိုဒေါင်နှင့် ကုန်ပစ္စည်းများကို ခြေရာခံသည့်စနစ်'), -- Source record ID: 2 + (3, 'လူ့စွမ်းအားအရင်းအမြစ် လုပ်ငန်းစဉ်များနှင့် ခွင့်ရက်များကို ခြေရာခံသည့်စနစ်'), -- Source record ID: 3 + (4, 'ဝယ်ယူရန်တောင်းဆိုမှုများနှင့် အတည်ပြုခြင်းလုပ်ငန်းစဉ်'), -- Source record ID: 4 + (5, 'အကူအညီတောင်းခံမှုများနှင့် ပြဿနာများကို ခြေရာခံရန် ဒက်ရှ်ဘုတ်'), -- Source record ID: 5 + (6, 'ထောက်ပံ့ရေးကွင်းဆက်ကို ခြေရာခံသည့်စနစ်'), -- Source record ID: 6 + (7, 'လုပ်ငန်းအချင်းချင်း ပင်လယ်စာရောင်းဝယ်နိုင်သည့် အွန်လိုင်းစျေးကွက်'), -- Source record ID: 7 + (8, 'လျှပ်စစ်၊ ရေ စသည့် ဝန်ဆောင်ခများကို အလိုအလျောက် ငွေတောင်းခံသည့်စနစ်'), -- Source record ID: 8 + (9, 'သင်္ဘောနှင့် ကုန်ပစ္စည်းများကို ခြေရာခံရန် UI'), -- Source record ID: 9 + (10, 'စမတ်စိုက်ပျိုးရေးဆိုင်ရာ အချက်အလက်ခွဲခြမ်းစိတ်ဖြာမှု ဒက်ရှ်ဘုတ်'), -- Source record ID: 10 + (11, 'ဒေသခံဆိုင်များအတွက် အွန်လိုင်းချိတ်ဆက်အသုံးပြုနိုင်သော POS စနစ်'), -- Source record ID: 11 + (12, 'ကြိုတင်မှာယူခြင်းနှင့် ခရီးသွားလမ်းညွှန် အက်ပ်'), -- Source record ID: 12 + (13, 'အဓိကအရင်းအမြစ် စီမံကိန်းရေးဆွဲမှု မော်ဂျူးများ'), -- Source record ID: 13 + (14, 'ပြန်လည်အသုံးချနိုင်သော စွမ်းအင် စောင့်ကြည့်မှု အချက်အလက်များ'), -- Source record ID: 14 + (15, 'ပြည်နယ်ကောလိပ်များအတွက် အွန်လိုင်းသင်ကြားရေးစနစ်'), -- Source record ID: 15 + (16, 'ကုန်ပစ္စည်းစာရင်းနှင့် အရည်အသွေးထိန်းချုပ်မှု မှတ်တမ်းများ'), -- Source record ID: 16 + (17, 'ငါးဖမ်းလုပ်ငန်း ခွဲတမ်းများနှင့် မြေပုံ UI'), -- Source record ID: 17 + (18, 'ဆေးခန်းလူနာ စီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 18 + (19, 'ပို့ကုန် ထောက်ပံ့ပို့ဆောင်ရေး စီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 19 + (20, 'စက်ရုံထုတ်လုပ်မှု ခြေရာခံမှတ်တမ်း'), -- Source record ID: 20 + (23, 'Q1 အတွက် POS နှင့် ဆိုင်မျက်နှာစာ ပြုပြင်မွမ်းမံခြင်း'), -- Source record ID: 23 + (24, 'Q1 အတွက် အကူအညီပေးရေးပေါ်တယ်နှင့် SLA လုပ်ငန်းစဉ်'), -- Source record ID: 24 + (25, 'Q2 အတွက် လစာစီမံခန့်ခွဲမှုနှင့် အတည်ပြုခြင်းများကို ပြုပြင်မွမ်းမံခြင်း'), -- Source record ID: 25 + (26, 'ရောင်းချသူ စတင်မှတ်ပုံတင်ခြင်းနှင့် အတည်ပြုခြင်းဘုတ်'), -- Source record ID: 26 + (27, 'ပို့ဆောင်မှုအချိန်ဇယားနှင့် လမ်းကြောင်းအပ်ဒိတ်များ'), -- Source record ID: 27 + (28, 'ကွင်းဆင်းအကူအညီပေးရေး အက်ပ်'), -- Source record ID: 28 + (29, 'အရောင်းအချက်အလက်ခွဲခြမ်းစိတ်ဖြာမှုနှင့် KPI စနစ် စတင်အသုံးချခြင်း'), -- Source record ID: 29 + (30, 'အာမခံတောင်းဆိုမှုများ လက်ခံခြင်းနှင့် စိစစ်ခြင်းလုပ်ငန်းစဉ်'), -- Source record ID: 30 + (31, 'ကုန်ပစ္စည်းအခြေအနေနှင့် ပို့ဆောင်ရေးစီမံကိန်း'), -- Source record ID: 31 + (32, 'ငွေတောင်းခံမှုနှင့် ရှင်းတမ်း ပြုပြင်မွမ်းမံခြင်းများ'), -- Source record ID: 32 + (33, 'ဖောက်သည်စောင့်ရှောက်ရေးပေါ်တယ်နှင့် ကိစ္စမှတ်စုများ'), -- Source record ID: 33 + (34, 'နှစ်ကုန်စတင်ဖွင့်လှစ်ရန် လုပ်ငန်းဆောင်ရွက်မှု စီမံကိန်း'), -- Source record ID: 34 + (35, 'API တိုက်ရိုက်ဖန်တီးမှု စမ်းသပ်ခြင်း'), -- Source record ID: 35 + (36, 'ဖျက်ရန်'), -- Source record ID: 36 + (38, 'ဖျက်ရန်') -- Source record ID: 38 +) AS v("Id", "Value") +WHERE "Projects"."Id" = v."Id" + AND ("DescriptionMy" IS NULL OR btrim("DescriptionMy") = ''); + +-- Tasks: TitleMy +UPDATE "Tasks" +SET "TitleMy" = v."Value" +FROM ( + VALUES + (1, 'ပေါ်တယ်ပင်မစာမျက်နှာ ပြုပြင်ခြင်း'), -- Source record ID: 1 + (2, 'အကောင့်ဝင်ခြင်းနှင့် အခန်းကဏ္ဍအလိုက် လမ်းကြောင်းသတ်မှတ်ခြင်း'), -- Source record ID: 2 + (3, 'ဖောက်သည်ကိုယ်ရေးအချက်အလက် ဖြည့်သွင်းပုံစံ'), -- Source record ID: 3 + (4, 'ငွေပေးချေမှုစာမျက်နှာ ပြုပြင်ရှင်းလင်းခြင်း'), -- Source record ID: 4 + (5, 'အသိပေးချက်ဗဟိုစနစ်'), -- Source record ID: 5 + (6, 'ဖောက်သည်စာရင်း ထုတ်ယူခြင်း'), -- Source record ID: 6 + (7, 'လုပ်ဆောင်ချက်စစ်ဆေးမှတ်တမ်း စာမျက်နှာ'), -- Source record ID: 7 + (8, 'အသွင်အပြင် နောက်ဆုံးပြင်ဆင်ခြင်း'), -- Source record ID: 8 + (9, 'ကုန်ပစ္စည်းလက်ကျန် ဒက်ရှ်ဘုတ်ကတ်များ'), -- Source record ID: 9 + (10, 'ဂိုဒေါင်ပစ္စည်း ရှာဖွေခြင်း'), -- Source record ID: 10 + (11, 'ကုန်ပစ္စည်းလက်ကျန်နည်းပါးမှု သတိပေးချက် ပြင်ဆင်ခြင်း'), -- Source record ID: 11 + (12, 'ကုန်ပစ္စည်းလက်ခံ ဖြည့်သွင်းပုံစံ'), -- Source record ID: 12 + (13, 'ကုန်ပစ္စည်း ဘားကုဒ်စာမျက်နှာ'), -- Source record ID: 13 + (14, 'ကုန်ပစ္စည်းစာရင်း အစီရင်ခံစာ ထုတ်ယူခြင်း'), -- Source record ID: 14 + (15, 'ကုန်ပစ္စည်းလက်ကျန် ပြင်ဆင်မှုမှတ်တမ်း စစ်ဆေးခြင်း'), -- Source record ID: 15 + (16, 'ရောင်းချသူများ ချိတ်ဆက်သတ်မှတ်ခြင်း'), -- Source record ID: 16 + (17, 'ဝန်ထမ်းစာရင်း ချိတ်ဆက်တစ်ပြေးညီပြုလုပ်ခြင်း'), -- Source record ID: 17 + (18, 'ခွင့်တောင်းဆိုမှု လုပ်ငန်းစဉ်'), -- Source record ID: 18 + (19, 'တက်ရောက်မှု အကျဉ်းချုပ်'), -- Source record ID: 19 + (20, 'အားလပ်ရက်ပြက္ခဒိန် ပြင်ဆင်ခြင်း'), -- Source record ID: 20 + (21, 'ဌာန ခွဲဝေသတ်မှတ်ခြင်း'), -- Source record ID: 21 + (22, 'လစာစာရင်း ထုတ်ယူမှုပုံစံ ပြင်ဆင်ခြင်း'), -- Source record ID: 22 + (23, 'မန်နေဂျာအတည်ပြု စာမျက်နှာ'), -- Source record ID: 23 + (24, 'ဝန်ထမ်းဒက်ရှ်ဘုတ် ပြုပြင်မွမ်းမံခြင်း'), -- Source record ID: 24 + (25, 'ဝယ်ယူရန်တောင်းဆိုမှု စာရင်း'), -- Source record ID: 25 + (26, 'ရောင်းချသူ ရှာဖွေခြင်းစာမျက်နှာ'), -- Source record ID: 26 + (27, 'အတည်ပြုခြင်း လုပ်ငန်းစဉ် ပြင်ဆင်ခြင်း'), -- Source record ID: 27 + (28, 'ဘတ်ဂျက် သတိပေးဆိုင်းဘုတ်'), -- Source record ID: 28 + (29, 'ဝယ်ယူရေး အကျဉ်းချုပ် အစီရင်ခံစာ'), -- Source record ID: 29 + (30, 'ပူးတွဲဖိုင် ကြည့်ရှုခြင်း'), -- Source record ID: 30 + (31, 'တောင်းဆိုမှု ကြာမြင့်ချိန် အခြေအနေ'), -- Source record ID: 31 + (32, 'အတည်ပြုခြင်း မှတ်ချက်များ'), -- Source record ID: 32 + (33, 'အကူအညီတောင်းခံမှု တန်းစီဘုတ်'), -- Source record ID: 33 + (34, 'ဦးစားပေးအဆင့် အရောင်စည်းမျဉ်းများ'), -- Source record ID: 34 + (35, 'တာဝန်ပေးခံရသူ အမြန်စစ်ထုတ်ခြင်း'), -- Source record ID: 35 + (36, 'သတ်မှတ်ရက်ကျော်လွန်မှု သတိပေးဆိုင်းဘုတ်'), -- Source record ID: 36 + (37, 'ဖောက်သည်မှတ်ချက် မှတ်တမ်း'), -- Source record ID: 37 + (38, 'မေးလေ့ရှိသောမေးခွန်းများ ဘေးဘက်စာမျက်နှာ'), -- Source record ID: 38 + (39, 'SLA အကျဉ်းချုပ် ဇယား'), -- Source record ID: 39 + (40, 'အသိပေးချက် ပုံစံများ'), -- Source record ID: 40 + (41, 'ဖောက်သည် အစီရင်ခံစာ'), -- Source record ID: 41 + (42, 'ဒေတာဘေ့စ် ဖွဲ့စည်းပုံ ပြင်ဆင်ခြင်း'), -- Source record ID: 42 + (43, 'API ချိတ်ဆက်ခြင်း'), -- Source record ID: 43 + (44, 'လမ်းကြောင်း အကောင်းဆုံးပြုလုပ်ခြင်း UI'), -- Source record ID: 44 + (45, 'ယာဉ်မောင်း ကုန်ပစ္စည်းစာရင်း အစီရင်ခံစာ'), -- Source record ID: 45 + (46, 'UAT အတည်ပြုလက်မှတ်'), -- Source record ID: 46 + (47, 'ရောင်းချသူ မှတ်ပုံတင်ဖြည့်သွင်းပုံစံ'), -- Source record ID: 47 + (48, 'ကုန်ပစ္စည်းစာရင်း ဇယား'), -- Source record ID: 48 + (49, 'လေလံစနစ် စည်းမျဉ်းများ'), -- Source record ID: 49 + (50, 'အာမခံငွေပေးချေမှု ချိတ်ဆက်စနစ်'), -- Source record ID: 50 + (51, 'မိုဘိုင်း တုံ့ပြန်ပြသမှုပုံစံ'), -- Source record ID: 51 + (52, 'မီတာဖတ်ရှုချက် တစ်ပြေးညီပြုလုပ်ခြင်း API'), -- Source record ID: 52 + (53, 'ငွေတောင်းခံလွှာ PDF ထုတ်လုပ်စနစ်'), -- Source record ID: 53 + (54, 'သတ်မှတ်ရက်ကျော်လွန်မှု သတိပေးစနစ်'), -- Source record ID: 54 + (55, 'အခွန်နှုန်းတွက်ချက်မှု လုပ်ဆောင်ချက်'), -- Source record ID: 55 + (56, 'လုပ်ဆောင်ချက်စစ်ဆေးမှတ်တမ်း မှတ်တမ်းကြည့်ရှုခြင်း'), -- Source record ID: 56 + (57, 'သင်္ဘောဆိုက်ကပ်မှု အချိန်ဇယား'), -- Source record ID: 57 + (58, 'ကုန်ပစ္စည်းစာရင်းဒေတာ ခွဲခြမ်းဖတ်ရှုခြင်း'), -- Source record ID: 58 + (59, 'ဆိပ်ခံနေရာ မြေပုံဂရပ်ဖစ်'), -- Source record ID: 59 + (60, 'အကောက်ခွန်အခြေအနေ အမှတ်အသားများ'), -- Source record ID: 60 + (61, 'နေ့စဉ်တန်ချိန် စုစည်းတွက်ချက်ခြင်း'), -- Source record ID: 61 + (62, 'IoT အာရုံခံကိရိယာ ချိတ်ဆက်ခြင်း'), -- Source record ID: 62 + (63, 'မိုးလေဝသ အချက်အလက်ခွဲခြမ်းစိတ်ဖြာ ခန့်မှန်းခြင်း'), -- Source record ID: 63 + (64, 'သီးနှံစက်ဝန်း အဓိကမှတ်တိုင်များ'), -- Source record ID: 64 + (65, 'အထွက်နှုန်း ခန့်မှန်းမော်ဒယ်'), -- Source record ID: 65 + (66, 'သတိပေးစံနှုန်း ပြင်ဆင်ခြင်း'), -- Source record ID: 66 + (67, 'စက်တွင်းသိုလှောင်မှု အစားထိုးအသုံးပြုခြင်း'), -- Source record ID: 67 + (68, 'ဘားကုဒ်ဖတ်စက် အထောက်အပံ့'), -- Source record ID: 68 + (69, 'ပြေစာအစိတ်အပိုင်း ဒီဇိုင်း'), -- Source record ID: 69 + (70, 'အလုပ်ချိန်အံဆွဲ ငွေရှင်းခြင်း'), -- Source record ID: 70 + (71, 'လျှော့စျေးကုဒ် စနစ်'), -- Source record ID: 71 + (72, 'ဟိုတယ်ရရှိနိုင်မှု ပြက္ခဒိန်များ'), -- Source record ID: 72 + (73, 'ခရီးလမ်းညွှန် အဆင့်သတ်မှတ်မှတ်တမ်းများ'), -- Source record ID: 73 + (74, 'ငွေကြေးအမျိုးအစားစုံ ရွေးချယ်ခြင်း'), -- Source record ID: 74 + (75, 'အင်တာနက်မလိုသော မြေပုံအထုပ်များ ထုတ်ယူခြင်း'), -- Source record ID: 75 + (76, 'သုံးသပ်ချက်များ လျှောပြကွက် တုံ့ပြန်ချက်'), -- Source record ID: 76 + (77, 'အဖွဲ့အစည်းပုံစံဇယား စနစ်'), -- Source record ID: 77 + (78, 'ပိုင်ဆိုင်မှုပစ္စည်း သက်တမ်းတန်ဖိုးလျော့တွက်ချက်ခြင်း'), -- Source record ID: 78 + (79, 'ဝယ်ယူမှုအမိန့် လုပ်ငန်းစဉ် အတည်ပြုခြင်း'), -- Source record ID: 79 + (80, 'ကုန်ပစ္စည်းစာရင်း ပြန်လည်တိုက်ဆိုင်စစ်ဆေးခြင်း'), -- Source record ID: 80 + (81, 'SSO ဆက်တင်များ'), -- Source record ID: 81 + (82, 'အင်ဗာတာ အမှားရှာဖွေဖတ်ရှုခြင်း'), -- Source record ID: 82 + (83, 'KWh ထုတ်လုပ်မှု မျဉ်းပြဇယားများ'), -- Source record ID: 83 + (84, 'ဘက်ထရီယိုယွင်းမှု သတိပေးခြင်း'), -- Source record ID: 84 + (85, 'ပြုပြင်ထိန်းသိမ်းရေး ခွဲဝေပို့ဆောင်မှု အစီအစဉ်'), -- Source record ID: 85 + (86, 'မိုးလေဝသသက်ရောက်မှု ခန့်မှန်းခြင်း'), -- Source record ID: 86 + (87, 'ကျောင်းသားစာရင်းသွင်းမှု ဇယား'), -- Source record ID: 87 + (88, 'မေးခွန်းလွှာဖန်တီးမှု စာမျက်နှာ'), -- Source record ID: 88 + (89, 'ဗီဒီယိုသင်ခန်းစာဖွင့်စက် ချိတ်ဆက်အစိတ်အပိုင်း'), -- Source record ID: 89 + (90, 'အမှတ်ပေးစံနှုန်း တွက်ချက်ခြင်း'), -- Source record ID: 90 + (91, 'လက်မှတ်ထုတ်လုပ်စနစ်'), -- Source record ID: 91 + (92, 'စိုထိုင်းဆပါဝင်မှု စစ်ဆေးစာရင်း'), -- Source record ID: 92 + (93, 'ဆီလိုသိုလှောင်မှု ခြေရာခံခြင်း'), -- Source record ID: 93 + (94, 'လက်ကားစျေးနှုန်း စာရင်းများ'), -- Source record ID: 94 + (95, 'အစုလိုက်ကုန်ပစ္စည်းပို့ဆောင်မှု စောင့်ကြည့်ခြင်း'), -- Source record ID: 95 + (96, 'ချိန်ခွင် ချိန်ညှိမှု မှတ်တမ်း'), -- Source record ID: 96 + (97, 'သင်္ဘော GPS တည်နေရာ အချက်အလက်စီးကြောင်း'), -- Source record ID: 97 + (98, 'မျိုးစိတ်အလိုက် ရိတ်သိမ်းမှု ခြေရာခံခြင်း'), -- Source record ID: 98 + (99, 'ဇုန်ကန့်သတ်ချက်များ'), -- Source record ID: 99 + (100, 'ရာသီအလိုက် ခွဲတမ်းရေတွက်ကိရိယာများ') -- Source record ID: 100 +) AS v("Id", "Value") +WHERE "Tasks"."Id" = v."Id" + AND ("TitleMy" IS NULL OR btrim("TitleMy") = ''); + +-- Tasks: DescriptionMy +UPDATE "Tasks" +SET "DescriptionMy" = v."Value" +FROM ( + VALUES + (1, 'ရန်ကုန်ဖောက်သည်ဝန်ဆောင်မှုပေါ်တယ်၏ ပင်မစာမျက်နှာကို ပြုပြင်ရှင်းလင်းခြင်း'), -- Source record ID: 1 + (2, 'အကောင့်ဝင်စီးဆင်းမှုနှင့် ပြန်လည်ညွှန်ပို့မှု စည်းမျဉ်းများ'), -- Source record ID: 2 + (3, 'ကိုယ်ရေးအချက်အလက်များကို ပြင်ဆင်ခြင်းနှင့် မှန်ကန်မှုစစ်ဆေးခြင်း'), -- Source record ID: 3 + (4, 'ငွေပေးချေမှုအပိုင်းရှိ UI နှင့် စစ်ထုတ်မှုများကို ပြုပြင်ရှင်းလင်းခြင်း'), -- Source record ID: 4 + (5, 'အသိပေးချက်စာရင်းနှင့် မဖတ်ရသေးကြောင်း ပြသသည့်အမှတ်အသား'), -- Source record ID: 5 + (6, 'ဖောက်သည်အချက်အလက်များကို CSV / Excel ဖိုင်အဖြစ် ထုတ်ယူခြင်း'), -- Source record ID: 6 + (7, 'လုပ်ဆောင်ချက်မှတ်တမ်းနှင့် ဖြစ်စဉ်မှတ်တမ်းစာမျက်နှာ'), -- Source record ID: 7 + (8, 'နေရာလွတ်နှင့် အရောင်များကို နောက်ဆုံးအဆင့် ပြုပြင်မွမ်းမံခြင်း'), -- Source record ID: 8 + (9, 'ဂိုဒေါင်အကျဉ်းချုပ်ကတ်များနှင့် အရေအတွက်ပြကိန်းများ'), -- Source record ID: 9 + (10, 'ကုန်ပစ္စည်းများကို မြန်ဆန်စွာ ရှာဖွေပြီး စစ်ထုတ်နိုင်ရန်'), -- Source record ID: 10 + (11, 'လက်ကျန်နည်းပါးသည့်အခါ သတိပေးအမှတ်အသားများ ပြသခြင်း'), -- Source record ID: 11 + (12, 'လက်ခံရရှိသော ကုန်ပစ္စည်းများကို မှတ်တမ်းတင်ရန် ဖြည့်သွင်းပုံစံ'), -- Source record ID: 12 + (13, 'ဘားကုဒ်ဖြင့် ရှာဖွေခြင်းနှင့် ကုန်ပစ္စည်းအသေးစိတ်စာမျက်နှာ'), -- Source record ID: 13 + (14, 'စီမံခန့်ခွဲမှုအတွက် ကုန်ပစ္စည်းလက်ကျန်အကျဉ်းချုပ်ကို ထုတ်ယူခြင်း'), -- Source record ID: 14 + (15, 'ပြင်ဆင်ပြောင်းလဲထားသည့် မှတ်တမ်းများကို ပြန်လည်သုံးသပ်ခြင်း'), -- Source record ID: 15 + (16, 'ရောင်းချသူများကို ကုန်ပစ္စည်းစာရင်းနှင့် ချိတ်ဆက်သတ်မှတ်ခြင်း'), -- Source record ID: 16 + (17, 'ဝန်ထမ်းစာရင်းနှင့် အခန်းကဏ္ဍများကို တစ်ပြေးညီဖြစ်အောင် ချိတ်ဆက်ခြင်း'), -- Source record ID: 17 + (18, 'ခွင့်တောင်းဆိုမှုနှင့် အတည်ပြုခြင်းအဆင့်များ'), -- Source record ID: 18 + (19, 'နေ့စဉ်တက်ရောက်မှုနှင့် လစဉ်အကျဉ်းချုပ်'), -- Source record ID: 19 + (20, 'ကုမ္ပဏီအားလပ်ရက်ပြက္ခဒိန်ကို ထိန်းသိမ်းခြင်း'), -- Source record ID: 20 + (21, 'ဝန်ထမ်းမှတ်တမ်းများအတွက် ဌာနခွဲဝေသတ်မှတ်ခြင်း'), -- Source record ID: 21 + (22, 'လစာစာရင်း ထုတ်ယူမည့်ပုံစံကို ပြင်ဆင်ခြင်း'), -- Source record ID: 22 + (23, 'မန်နေဂျာများအတွက် အတည်ပြုခြင်းစာမျက်နှာ'), -- Source record ID: 23 + (24, 'ဝန်ထမ်းမြင်ကွင်းအတွက် UI ကို ပြုပြင်ရှင်းလင်းခြင်း'), -- Source record ID: 24 + (25, 'ဝယ်ယူရေးတောင်းဆိုမှုစာရင်းနှင့် အခြေအနေ'), -- Source record ID: 25 + (26, 'ရောင်းချသူ ရှာဖွေခြင်းနှင့် အသေးစိတ်စာမျက်နှာ'), -- Source record ID: 26 + (27, 'အတည်ပြုခြင်းအဆင့်များနှင့် လမ်းကြောင်းသတ်မှတ်မှု စည်းမျဉ်းများ'), -- Source record ID: 27 + (28, 'ဝယ်ယူမှုက ဘတ်ဂျက်ကျော်လွန်သည့်အခါ ပြသမည့် သတိပေးဆိုင်းဘုတ်'), -- Source record ID: 28 + (29, 'အတည်ပြုခြင်းနှင့် ဘတ်ဂျက်များအတွက် အကျဉ်းချုပ်စာမျက်နှာ'), -- Source record ID: 29 + (30, 'တင်ထားသော ဝယ်ယူမှု ပူးတွဲဖိုင်များကို ကြိုတင်ကြည့်ရှုခြင်း'), -- Source record ID: 30 + (31, 'ဝယ်ယူရေးတောင်းဆိုမှုများအတွက် ကြာမြင့်ချိန်ပြ အညွှန်း'), -- Source record ID: 31 + (32, 'အတည်ပြုခြင်းအဆင့်များအတွင်း မှတ်ချက်ဆွေးနွေးမှု'), -- Source record ID: 32 + (33, 'ကန်ဘန်းပုံစံ အကူအညီတောင်းခံမှု တန်းစီဘုတ်'), -- Source record ID: 33 + (34, 'ဦးစားပေးအမှတ်အသားများနှင့် ဘုတ်အရောင်များ'), -- Source record ID: 34 + (35, 'တာဝန်ပေးခံရသူအလိုက် အမြန်စစ်ထုတ်ခြင်း'), -- Source record ID: 35 + (36, 'သတ်မှတ်ရက်ကျော်လွန်သော တောင်းဆိုမှုများကို ထင်ရှားစွာပြသခြင်း'), -- Source record ID: 36 + (37, 'ဖောက်သည်ပြန်ကြားချက်များနှင့် မှတ်စုများကို မှတ်တမ်းတင်ခြင်း'), -- Source record ID: 37 + (38, 'အကူအညီပေးရေးစာမျက်နှာရှိ အသုံးဝင်သော မေးလေ့ရှိသည့်မေးခွန်းများ'), -- Source record ID: 38 + (39, 'အကူအညီပေးရေး SLA အကျဉ်းချုပ်နှင့် ဇယား'), -- Source record ID: 39 + (40, 'အကူအညီပေးရေး အသိပေးချက်များအတွက် ပုံစံစာကြည့်တိုက်'), -- Source record ID: 40 + (41, 'ဖောက်သည်အစီရင်ခံစာကို ပြင်ဆင်ခြင်း'), -- Source record ID: 41 + (42, 'ထောက်ပံ့ရေးကွင်းဆက် အဖွဲ့အစည်းများအတွက် ဒေတာဘေ့စ်ဇယားများ ဒီဇိုင်းဆွဲခြင်း'), -- Source record ID: 42 + (43, 'ခြေရာခံဆက်သွယ်ပေါက်များကို ရောင်းချသူ API များနှင့် ချိတ်ဆက်ခြင်း'), -- Source record ID: 43 + (44, 'မြေပုံမြင်ကွင်း ဒက်ရှ်ဘုတ် တည်ဆောက်ခြင်း'), -- Source record ID: 44 + (45, 'ပို့ဆောင်ရေးအဖွဲ့များအတွက် ထုတ်ယူမည့်ပုံစံကို ပြင်ဆင်ခြင်း'), -- Source record ID: 45 + (46, 'ဒေသဆိုင်ရာ လုပ်ငန်းဆောင်ရွက်မှု ဦးဆောင်သူနှင့် နောက်ဆုံးအတည်ပြုစစ်ဆေးခြင်း'), -- Source record ID: 46 + (47, 'အတည်ပြုထားသော ပင်လယ်စာဖြန့်ချိသူများအတွက် လုပ်ငန်းစဉ် ပြင်ဆင်ခြင်း'), -- Source record ID: 47 + (48, 'လတ်တလောရရှိနိုင်သော ဖမ်းဆီးရရှိမှုအမျိုးအစားများနှင့် စာရင်းများကို ပြသခြင်း'), -- Source record ID: 48 + (49, 'အချိန်နှင့်တပြေးညီ စျေးနှုန်းပြောင်းလဲမှု လုပ်ဆောင်ချက်'), -- Source record ID: 49 + (50, 'Stripe နှင့် ပြည်တွင်းဘဏ်လုပ်ငန်း ငွေပေးချေမှုချိတ်ဆက်မှု'), -- Source record ID: 50 + (51, 'Android တက်ဘလက်များတွင် ပုံစံပြသမှု ချောမွေ့စေရန်'), -- Source record ID: 51 + (52, 'သုံးစွဲမှုမှတ်တမ်းများ ရယူရန် အလုပ်လုပ်စနစ်'), -- Source record ID: 52 + (53, 'စံသတ်မှတ်ထားသော ပုံနှိပ်ပုံစံများကို ဒီဇိုင်းဆွဲခြင်း'), -- Source record ID: 53 + (54, 'ဆိုင်းငံ့နေသော ငွေပေးချေမှုများအတွက် SMS အသိပေးချက်များ'), -- Source record ID: 54 + (55, 'အသုံးပြုသည့် ယူနစ်များအပေါ် အခြေခံသည့် အဆင့်လိုက်တွက်ချက်မှု စာသားအစီအစဉ်'), -- Source record ID: 55 + (56, 'စီမံခန့်ခွဲသူ UI တွင် ကိုယ်တိုင်ပြင်ဆင်မှုများကို ခြေရာခံခြင်း'), -- Source record ID: 56 + (57, 'ဝင်ရောက်လာမည့် သင်္ဘောများအတွက် အပြန်အလှန်တုံ့ပြန်နိုင်သော အစီအစဉ်ဘုတ်'), -- Source record ID: 57 + (58, 'Excel/CSV အကောက်ခွန်စာရွက်စာတမ်းများ ထည့်သွင်းဖတ်ရှုသည့် လုပ်ဆောင်ချက်'), -- Source record ID: 58 + (59, 'သင်္ဘောဆိပ်ခံနေရာများ၏ ဖွဲ့စည်းပုံကို SVG ဖြင့် ခြေရာခံပြသခြင်း'), -- Source record ID: 59 + (60, 'အခွန်ရှင်းလင်းပြီး သို့မဟုတ် ဆိုင်းငံ့ထားသော မှတ်တမ်းများအတွက် အရောင်ညွှန်ပြချက်များ'), -- Source record ID: 60 + (61, 'စွမ်းဆောင်ရည်မြင့် SQL အစီရင်ခံစာမေးမြန်းချက်'), -- Source record ID: 61 + (62, 'မြေစိုထိုင်းဆဇယားကွက်များအတွက် အာရုံခံအချက်အလက် လက်ခံစနစ်'), -- Source record ID: 62 + (63, 'ပြင်ပဝန်ဆောင်မှု API မှ အချက်အလက်ပေးပို့မှုကတ်များ'), -- Source record ID: 63 + (64, 'ပန်းနှင့် ဟင်းသီးဟင်းရွက်စိုက်ခင်းများအတွက် အဆင့်လိုက်တိုးတက်မှု ပြသချက်'), -- Source record ID: 64 + (65, 'ရိတ်သိမ်းရာသီ ထုတ်လုပ်နိုင်စွမ်းအတွက် ဇယားခန့်မှန်းချက်များ'), -- Source record ID: 65 + (66, 'အနည်းဆုံးအရင်းအမြစ် သတိပေးစံနှုန်းများအတွက် ဆလိုက်ဘားများ'), -- Source record ID: 66 + (67, 'အင်တာနက်ချိတ်ဆက်မှု ပြတ်တောက်ပါက အသုံးပြုမည့် သိမ်းဆည်းမှုစနစ်'), -- Source record ID: 67 + (68, 'ထည့်သွင်းစာသားပုံစံ စစ်ဆေးသည့် လုပ်ဆောင်ချက် အပ်ဒိတ်များ'), -- Source record ID: 68 + (69, 'အပူပုံနှိပ်စက်အတွက် ပြေစာပုံစံ ပြသသည့်စနစ်'), -- Source record ID: 69 + (70, 'ငွေသားအံဆွဲ ပိတ်သိမ်းတွက်ချက်မှု မြင်ကွင်း'), -- Source record ID: 70 + (71, 'တစ်ပြေးညီနှုန်းထား ရာခိုင်နှုန်းလျှော့စျေးအတွက် ပြောင်းလဲအသုံးချနိုင်သော စည်းမျဉ်းများ'), -- Source record ID: 71 + (72, 'အခန်းရရှိနိုင်မှုကို ပြောင်းလဲပြသနိုင်သော ပုံစံအကွက်များ'), -- Source record ID: 72 + (73, 'အဆင့်သတ်မှတ်ချက်ခွဲခြမ်းမှုနှင့် ကျွမ်းကျင်မှုအမှတ်အသား ဒီဇိုင်းများ'), -- Source record ID: 73 + (74, 'ငွေလဲနှုန်း အလိုအလျောက်ပြောင်းလဲသည့် ကတ်များ'), -- Source record ID: 74 + (75, 'ဝေးလံခေါင်ဖျားဒေသများအတွက် ဒေတာပစ္စည်း ချုံ့သိမ်းသည့် လုပ်ဆောင်ချက်များ'), -- Source record ID: 75 + (76, 'ပုံနှင့်တကွ ပူးတွဲဖိုင်များပါသော ဖြည့်သွင်းပုံစံများ တင်သွင်းခြင်း'), -- Source record ID: 76 + (77, 'အပြန်အလှန်တုံ့ပြန်နိုင်သော အဆင့်ဆင့်အစီရင်ခံပုံစံများ'), -- Source record ID: 77 + (78, 'စာရင်းမှတ်တမ်းပြောင်းလဲချက်များကို ကိုင်တွယ်သည့် ဘဏ္ဍာရေးတွက်ချက်မှုများ'), -- Source record ID: 78 + (79, 'ဝယ်ယူမှုအမိန့် ခွဲဝေလမ်းကြောင်း စစ်ထုတ်မှုများ'), -- Source record ID: 79 + (80, 'ကုန်ပစ္စည်းစာရင်းကွာဟချက် အကဲဖြတ်အစီရင်ခံစာများ'), -- Source record ID: 80 + (81, 'ကော်ပိုရိတ်လုံခြုံရေး ကိုယ်ရေးအချက်အလက်စာရင်း ချိတ်ဆက်မှုများ'), -- Source record ID: 81 + (82, 'အမှားကုဒ်များကို အဆက်မပြတ်ဖတ်ရှုသည့် လုပ်ဆောင်ချက်များ'), -- Source record ID: 82 + (83, 'အချိန်စဉ်အလိုက် တိုင်းတာချက်များ စီးဆင်းမှု ဆက်တင်များ'), -- Source record ID: 83 + (84, 'ကြိုတင်အသိပေး အီးမေးလ်ပို့စနစ် စည်းမျဉ်းများ'), -- Source record ID: 84 + (85, 'တည်နေရာအနီးအဝေးအပေါ်မူတည်၍ နည်းပညာရှင်များ ခွဲဝေခြင်း'), -- Source record ID: 85 + (86, 'မျှော်မှန်းမိုးအုံ့မှု တွက်ချက်ချက်များကို မြေပုံပေါ် ထပ်ဆင့်ပြသခြင်း'), -- Source record ID: 86 + (87, 'CSV အစုလိုက်လုပ်ဆောင်မှု မြင်ကွင်းများ'), -- Source record ID: 87 + (88, 'ဖြည့်သွင်းပုံစံအဆင့်ဆင့် တည်ဆောက်သည့် ကိရိယာ ဆက်တင်များ'), -- Source record ID: 88 + (89, 'စိတ်ကြိုက်ဗီဒီယိုဖွင့်စက် UI အစိတ်အပိုင်း ထိန်းချုပ်မှုများ'), -- Source record ID: 89 + (90, 'အလေးချိန်ပေးအမှတ် စုစုပေါင်းတွက်ချက်သည့် သင်္ချာလုပ်ဆောင်ချက်များ'), -- Source record ID: 90 + (91, 'လုံခြုံသောလက်မှတ်များဖြင့် အလိုအလျောက် PDF ထုတ်လုပ်ခြင်း'), -- Source record ID: 91 + (92, 'လက်ခံရရှိသောပစ္စည်း အရည်အသွေးစစ်ဆေးပုံစံများ'), -- Source record ID: 92 + (93, 'ထုထည်အလိုက် သိုလှောင်မှုပမာဏ ပြသသည့် အစိတ်အပိုင်း'), -- Source record ID: 93 + (94, 'အမြောက်အများစျေးနှုန်း တွက်ချက်မှု ဆက်တင်များ'), -- Source record ID: 94 + (95, 'ကုန်ပစ္စည်းဖြည့်ဆည်းပို့ဆောင်ရေး လုပ်ငန်းစဉ် အတည်ပြုစစ်ဆေးမှုများ'), -- Source record ID: 95 + (96, 'ကိရိယာများကို အချိန်အလိုက် စစ်ဆေးအတည်ပြုထားသည့် မှတ်တမ်းများ'), -- Source record ID: 96 + (97, 'Leaflet မြေပုံပေါ်ရှိ အချက်အလက်မြင်ကွင်း အလွှာ ချိတ်ဆက်မှုများ'), -- Source record ID: 97 + (98, 'စည်းမျဉ်းလိုက်နာမှု အစီရင်ခံစာများအတွက် အချက်အလက်ခွဲခြမ်းမှု'), -- Source record ID: 98 + (99, 'ဗဟုဂံနယ်နိမိတ် ဖြတ်ကျော်မှု စစ်ဆေးသည့် သတိပေးချက်များ'), -- Source record ID: 99 + (100, 'ရာသီအလိုက် ခွဲတမ်းရေတွက်မှု တိုင်းတာကိရိယာများ') -- Source record ID: 100 +) AS v("Id", "Value") +WHERE "Tasks"."Id" = v."Id" + AND ("DescriptionMy" IS NULL OR btrim("DescriptionMy") = ''); + +-- Issues: TitleMy +UPDATE "Issues" +SET "TitleMy" = v."Value" +FROM ( + VALUES + (1, 'API စမ်းသပ်ခြင်း'), -- Source record ID: 1 + (2, 'Firewall ဆက်တင်များကို စစ်ဆေးခြင်း'), -- Source record ID: 2 + (3, 'ရန်ကုန် လက်လီရောင်းဝယ်ရေး အဆင့်မြှင့်စီမံကိန်း၏ လက်လီငွေရှင်းလုပ်ငန်းစဉ်အတွက် အသုံးပြုသူလိုအပ်ချက်များကို ပြန်လည်ပြင်ဆင်ခြင်း'), -- Source record ID: 3 + (4, 'ရန်ကုန် လက်လီရောင်းဝယ်ရေးစီမံကိန်းအတွက် POS စနစ်ကို ပေါင်းစည်းအကောင်အထည်ဖော်ခြင်း'), -- Source record ID: 4 + (5, 'ရန်ကုန် လက်လီရောင်းဝယ်ရေးစီမံကိန်း၏ ငွေကြေးအမျိုးအစားစုံ ငွေရှင်းလုပ်ငန်းစဉ်ကို အရည်အသွေးစစ်ဆေးခြင်း'), -- Source record ID: 5 + (6, 'မန္တလေး ဝန်ဆောင်မှုပေါ်တယ် ဖောက်သည်ဒက်ရှ်ဘုတ်အတွက် စနစ်ဖွဲ့စည်းပုံ ဒီဇိုင်းရေးဆွဲခြင်း'), -- Source record ID: 6 + (7, 'ဝန်ဆောင်မှုတောင်းဆိုမှု တင်သွင်းပုံစံနှင့် အခြေအနေစီမံခန့်ခွဲမှု တည်ဆောက်ခြင်း'), -- Source record ID: 7 + (8, 'ဖောက်သည်ဝန်ဆောင်မှုပေါ်တယ် တောင်းဆိုမှုလမ်းကြောင်းကို UAT စမ်းသပ်ခြင်း'), -- Source record ID: 8 + (9, 'လစာစီမံခန့်ခွဲမှုစနစ်အတွက် ဥပဒေအရသတ်မှတ်ထားသော အခွန်တွက်ချက်မှုလိုအပ်ချက်များကို ပြန်လည်သုံးသပ်ခြင်း'), -- Source record ID: 9 + (10, 'အလိုအလျောက် လစဉ်လစာတွက်ချက်မှုစနစ် တည်ဆောက်ခြင်း'), -- Source record ID: 10 + (11, 'အဟောင်းဒေတာနှင့် လစာရှင်းတမ်း PDF ထုတ်လုပ်မှုမှန်ကန်မှုကို အတည်ပြုခြင်း'), -- Source record ID: 11 + (12, 'ရောင်းချသူစတင်မှတ်ပုံတင်မှု API ဖွဲ့စည်းပုံနှင့် အထောက်အထားစစ်ဆေးမှုလုပ်ငန်းစဉ်များ သတ်မှတ်ခြင်း'), -- Source record ID: 12 + (13, 'ရောင်းချသူမှတ်ပုံတင် အဆင့်ဆင့်ဖြည့်သွင်းပုံစံနှင့် စာရွက်စာတမ်းတင်သွင်း UI ဖန်တီးခြင်း'), -- Source record ID: 13 + (14, 'ရောင်းချသူစာရွက်စာတမ်းသိမ်းဆည်းရာနေရာ၏ လုံခြုံရေးစစ်ဆေးမှုနှင့် အတည်ပြုခြင်း'), -- Source record ID: 14 + (15, 'အချိန်နှင့်တပြေးညီ GPS ခြေရာခံမှုလိုအပ်ချက်များနှင့် နောက်ခံလုပ်ငန်းစဉ် တစ်ပြေးညီလုပ်ဆောင်ချိန်ကို ဆန်းစစ်ခြင်း'), -- Source record ID: 15 + (16, 'Google Maps API ပေါင်းစည်းပြီး ယာဉ်မောင်းတည်နေရာ ရယူသည့် နောက်ခံစနစ်တည်ဆောက်ခြင်း'), -- Source record ID: 16 + (17, 'ယာဉ်မောင်းအက်ပ်၏ နောက်ခံတည်နေရာဝန်ဆောင်မှုကို အစမှအဆုံး စမ်းသပ်ခြင်း'), -- Source record ID: 17 + (18, 'မော်လမြိုင် ကွင်းဆင်း အက်ပ်၏ အင်တာနက်မဲ့ဒေတာတစ်ပြေးညီပြုလုပ်မှု ကန့်သတ်ချက်များကို မှတ်တမ်းတင်ခြင်း'), -- Source record ID: 18 + (19, 'အင်တာနက်မဲ့အသုံးပြုမှုအတွက် SQLite စက်တွင်းဒေတာဘေ့စ်နှင့် တစ်ပြေးညီပြုလုပ်ရန် တန်းစီစာရင်း အကောင်အထည်ဖော်ခြင်း'), -- Source record ID: 19 + (20, 'အင်တာနက်မဲ့မှ အင်တာနက်ချိတ်ဆက်အသုံးပြုမှုသို့ ပြန်လည်ချိတ်ဆက်ခြင်းနှင့် ကွာဟချက်ဖြေရှင်းခြင်း စမ်းသပ်ခြင်း'), -- Source record ID: 20 + (21, 'ပုသိမ် အရောင်းဒက်ရှ်ဘုတ်အတွက် KPI တိုင်းတာချက်များနှင့် ဇယားမြင်ကွင်း သတ်မှတ်ချက်များ အပြီးသတ်ခြင်း'), -- Source record ID: 21 + (22, 'Redis သိမ်းဆည်းမှုအလွှာပါဝင်သော အရောင်းအစီရင်ခံစာ API များ တည်ဆောက်ခြင်း'), -- Source record ID: 22 + (23, 'ဒက်ရှ်ဘုတ်ဇယားများနှင့် အမှောင်ရောင်ပြသမှု ခလုတ်ကို ဘရောက်ဇာအမျိုးမျိုးတွင် UI နှင့် အသုံးပြုသူအတွေ့အကြုံ စမ်းသပ်ခြင်း'), -- Source record ID: 23 + (24, 'အာမခံတောင်းဆိုမှု လုပ်ငန်းစဉ်စည်းမျဉ်းများနှင့် အတည်ပြုခြင်းအဆင့်ဆင့်ကို အစီအစဉ်ချခြင်း'), -- Source record ID: 24 + (25, 'အာမခံတောင်းဆိုမှုများ အလိုအလျောက်ဆုံးဖြတ်ပေးသည့် စည်းမျဉ်းစနစ် တည်ဆောက်ခြင်း'), -- Source record ID: 25 + (26, 'အာမခံတောင်းဆိုမှု အတည်ပြုလမ်းကြောင်းနှင့် အီးမေးလ်အသိပေးချက်များကို အခြေခံစစ်ဆေးခြင်း'), -- Source record ID: 26 + (27, 'ဂိုဒေါင်ကုန်ပစ္စည်းစာရင်းစီမံခန့်ခွဲမှုနှင့် သိုလှောင်ခန်းခွဲဝေမှု လိုအပ်ချက်များ စုစည်းခြင်း'), -- Source record ID: 27 + (28, 'ကုန်ပစ္စည်းခြေရာခံမှုအတွက် ဘားကုဒ်ဖတ်ခြင်း API ချိတ်ဆက်မှု အကောင်အထည်ဖော်ခြင်း'), -- Source record ID: 28 + (29, 'ပစ္စည်းစာရင်းဒေတာဘေ့စ်ကို ပမာဏမြင့်မားသော အမြင့်ဆုံးအသုံးပြုချိန်အခြေအနေဖြင့် စမ်းသပ်ခြင်း'), -- Source record ID: 29 + (30, 'စာရင်းသွင်းဝန်ဆောင်ခ လည်ပတ်မှု၏ အထူးအခြေအနေများနှင့် အချိုးကျငွေပြန်အမ်းစည်းမျဉ်းများကို ပြန်လည်သုံးသပ်ခြင်း'), -- Source record ID: 30 + (31, 'အလိုအလျောက် လစဉ်ထပ်တလဲလဲ ငွေတောင်းခံလွှာများအတွက် Stripe ငွေတောင်းခံမှု API ပေါင်းစည်းခြင်း'), -- Source record ID: 31 + (32, 'အလိုအလျောက် ငွေပေးချေရန်သတိပေးအီးမေးလ်များနှင့် မအောင်မြင်သောငွေပေးချေမှု ပြန်လည်ကြိုးစားမှုစနစ်ကို အတည်ပြုခြင်း'), -- Source record ID: 32 + (33, 'စကားပြောဝစ်ဂျက် ချိတ်ဆက်မှုနယ်ပယ်နှင့် ဖောက်သည်ဝန်ဆောင်မှု SLA သတိပေးစတင်မှုများ သတ်မှတ်ခြင်း'), -- Source record ID: 33 + (34, 'တိုက်ရိုက်စကားပြောဝစ်ဂျက်ကို လမ်းကြောင်းသတ်မှတ်စည်းမျဉ်းများဖြင့် ဖောက်သည်ပေါ်တယ်အတွင်း ထည့်သွင်းခြင်း'), -- Source record ID: 34 + (35, 'စကားပြောမှတ်တမ်းသိမ်းဆည်းမှုနှင့် ဝန်ဆောင်မှုပေးသူ ခွဲဝေသတ်မှတ်မှု လုပ်ဆောင်ချက်ကို အရည်အသွေးစစ်ဆေးခြင်း'), -- Source record ID: 35 + (36, 'အရင်းအမြစ်ခွဲဝေခြင်း ခြေရာခံမှုနှင့် အချိန်ဇယား UI လိုအပ်ချက်များကို အကဲဖြတ်ခြင်း'), -- Source record ID: 36 + (37, 'စီမံကိန်းအရင်းအမြစ် အချိန်ဇယားအတွက် အပြန်အလှန်တုံ့ပြန်နိုင်သော အချိန်ဇယားပြဇယား မြင်ကွင်း တည်ဆောက်ခြင်း'), -- Source record ID: 37 + (38, 'လုပ်ငန်းဆောင်ရွက်မှု အချိန်ဇယားမြင်ကွင်းရှိ ဆွဲရွှေ့ဖြစ်ရပ်စတင်မှုများကို အတည်ပြုခြင်း'), -- Source record ID: 38 + (39, 'UAT စမ်းသပ်ခြင်း'), -- Source record ID: 39 + (40, 'SIT စမ်းသပ်ခြင်း') -- Source record ID: 40 +) AS v("Id", "Value") +WHERE "Issues"."Id" = v."Id" + AND ("TitleMy" IS NULL OR btrim("TitleMy") = ''); + +-- Issues: DescriptionMy +UPDATE "Issues" +SET "DescriptionMy" = v."Value" +FROM ( + VALUES + (1, 'API စမ်းသပ်မှု'), -- Source record ID: 1 + (3, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 3 + (4, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 4 + (5, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 5 + (6, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 6 + (7, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 7 + (8, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 8 + (9, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 9 + (10, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 10 + (11, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 11 + (12, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 12 + (13, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 13 + (14, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 14 + (15, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 15 + (16, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 16 + (17, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 17 + (18, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 18 + (19, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 19 + (20, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 20 + (21, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 21 + (22, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 22 + (23, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 23 + (24, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 24 + (25, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 25 + (26, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 26 + (27, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 27 + (28, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 28 + (29, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 29 + (30, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 30 + (31, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 31 + (32, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 32 + (33, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 33 + (34, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 34 + (35, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 35 + (36, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 36 + (37, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 37 + (38, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်') -- Source record ID: 38 +) AS v("Id", "Value") +WHERE "Issues"."Id" = v."Id" + AND ("DescriptionMy" IS NULL OR btrim("DescriptionMy") = ''); + +-- Users: FirstNameMy +UPDATE "Users" +SET "FirstNameMy" = v."Value" +FROM ( + VALUES + (1, 'စစ်စတမ်'), -- Source record ID: 1 + (2, 'ကျော်ကျော်'), -- Source record ID: 2 + (3, 'မင်းမင်း'), -- Source record ID: 3 + (4, 'မောင်မောင်'), -- Source record ID: 4 + (5, 'အောင်အောင်'), -- Source record ID: 5 + (6, 'ဝေယံ'), -- Source record ID: 6 + (7, 'သူရ'), -- Source record ID: 7 + (8, 'စုစု'), -- Source record ID: 8 + (9, 'နန္ဒာ'), -- Source record ID: 9 + (10, 'ဖြိုးဖြိုး'), -- Source record ID: 10 + (11, 'ဇော်ဇော်'), -- Source record ID: 11 + (12, 'လှလှ'), -- Source record ID: 12 + (13, 'နွဲ့နွဲ့'), -- Source record ID: 13 + (14, 'အိအိ'), -- Source record ID: 14 + (15, 'သန္တာ'), -- Source record ID: 15 + (16, 'ခင်ခင်'), -- Source record ID: 16 + (17, 'ဆုမွန်'), -- Source record ID: 17 + (18, 'မွန်မွန်'), -- Source record ID: 18 + (19, 'ရီရီ'), -- Source record ID: 19 + (20, 'ပြည့်ပြည့်'), -- Source record ID: 20 + (21, 'မေ'), -- Source record ID: 21 + (22, 'ထက်ထက်'), -- Source record ID: 22 + (23, 'အေးအေး'), -- Source record ID: 23 + (24, 'ချောချော'), -- Source record ID: 24 + (25, 'မြမြ'), -- Source record ID: 25 + (26, 'လွင်လွင်'), -- Source record ID: 26 + (27, 'ဖြူဖြူ'), -- Source record ID: 27 + (28, 'မြတ်မြတ်'), -- Source record ID: 28 + (29, 'ငြိမ်းငြိမ်း'), -- Source record ID: 29 + (30, 'ကြူကြူ'), -- Source record ID: 30 + (31, 'စံစံ'), -- Source record ID: 31 + (32, 'စီစီ'), -- Source record ID: 32 + (33, 'မိုးမိုး'), -- Source record ID: 33 + (34, 'ယုယု'), -- Source record ID: 34 + (35, 'ပန်းပန်း'), -- Source record ID: 35 + (36, 'ခိုင်'), -- Source record ID: 36 + (37, 'သီရိ'), -- Source record ID: 37 + (38, 'နှင်း'), -- Source record ID: 38 + (39, 'ဝင့်'), -- Source record ID: 39 + (40, 'ချမ်း'), -- Source record ID: 40 + (41, 'အေး'), -- Source record ID: 41 + (42, 'ထူးအောင်'), -- Source record ID: 42 + (43, 'တက်စ်'), -- Source record ID: 43 + (44, 'သင်းထက်'), -- Source record ID: 44 + (46, 'မောင်'), -- Source record ID: 46 + (47, 'ရှိုင်း'), -- Source record ID: 47 + (48, 'အိုင်'), -- Source record ID: 48 + (49, 'ဇော်'), -- Source record ID: 49 + (50, 'အက်စ်ဒီအေ') -- Source record ID: 50 +) AS v("Id", "Value") +WHERE "Users"."Id" = v."Id" + AND ("FirstNameMy" IS NULL OR btrim("FirstNameMy") = ''); + +-- Users: LastNameMy +UPDATE "Users" +SET "LastNameMy" = v."Value" +FROM ( + VALUES + (1, 'အက်ဒမင်'), -- Source record ID: 1 + (2, 'အောင်'), -- Source record ID: 2 + (3, 'ထွန်း'), -- Source record ID: 3 + (4, 'နိုင်'), -- Source record ID: 4 + (5, 'ဦး'), -- Source record ID: 5 + (6, 'ထွန်း'), -- Source record ID: 6 + (7, 'ကို'), -- Source record ID: 7 + (8, 'ဝင်း'), -- Source record ID: 8 + (9, 'လှိုင်'), -- Source record ID: 9 + (10, 'အေး'), -- Source record ID: 10 + (11, 'လင်း'), -- Source record ID: 11 + (12, 'ခင်'), -- Source record ID: 12 + (13, 'အေး'), -- Source record ID: 13 + (14, 'မွန်'), -- Source record ID: 14 + (15, 'ဝင်း'), -- Source record ID: 15 + (16, 'ထွန်း'), -- Source record ID: 16 + (17, 'လှိုင်'), -- Source record ID: 17 + (18, 'အောင်'), -- Source record ID: 18 + (19, 'လွင်'), -- Source record ID: 19 + (20, 'နိုင်'), -- Source record ID: 20 + (21, 'ဇော်'), -- Source record ID: 21 + (22, 'ဦး'), -- Source record ID: 22 + (23, 'ခိုင်'), -- Source record ID: 23 + (24, 'အောင်'), -- Source record ID: 24 + (25, 'မြင့်'), -- Source record ID: 25 + (26, 'ထွန်း'), -- Source record ID: 26 + (27, 'စိုး'), -- Source record ID: 27 + (28, 'အောင်'), -- Source record ID: 28 + (29, 'ဝင်း'), -- Source record ID: 29 + (30, 'လှိုင်'), -- Source record ID: 30 + (31, 'အေး'), -- Source record ID: 31 + (32, 'ကို'), -- Source record ID: 32 + (33, 'သန့်'), -- Source record ID: 33 + (34, 'နိုင်'), -- Source record ID: 34 + (35, 'ဝေ'), -- Source record ID: 35 + (36, 'ခိုင်'), -- Source record ID: 36 + (37, 'အောင်'), -- Source record ID: 37 + (38, 'အေး'), -- Source record ID: 38 + (39, 'ဦး'), -- Source record ID: 39 + (40, 'မိုး'), -- Source record ID: 40 + (41, 'မွန်'), -- Source record ID: 41 + (42, 'လွင်'), -- Source record ID: 42 + (43, 'ယူဇာ'), -- Source record ID: 43 + (44, 'စံ'), -- Source record ID: 44 + (46, 'အေး'), -- Source record ID: 46 + (47, 'လွင်'), -- Source record ID: 47 + (48, 'ယူရီနာ'), -- Source record ID: 48 + (49, 'ဇော်'), -- Source record ID: 49 + (50, 'အက်စ်ဒီအက်စ်ဒီ') -- Source record ID: 50 +) AS v("Id", "Value") +WHERE "Users"."Id" = v."Id" + AND ("LastNameMy" IS NULL OR btrim("LastNameMy") = ''); + +-- notifications: TitleMy +UPDATE "notifications" +SET "TitleMy" = v."Value" +FROM ( + VALUES + (2, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 2 + (4, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 4 + (6, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 6 + (8, 'မှတ်ချက်အသစ်'), -- Source record ID: 8 + (10, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 10 + (11, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 11 + (14, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 14 + (15, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 15 + (16, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 16 + (17, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 17 + (18, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 18 + (19, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 19 + (20, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 20 + (21, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 21 + (22, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 22 + (24, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 24 + (25, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 25 + (30, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 30 + (32, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 32 + (33, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 33 + (34, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 34 + (35, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 35 + (36, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 36 + (41, 'မှတ်ချက်အသစ်'), -- Source record ID: 41 + (42, 'မှတ်ချက်အသစ်'), -- Source record ID: 42 + (43, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 43 + (44, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 44 + (45, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 45 + (46, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 46 + (47, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 47 + (48, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 48 + (49, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 49 + (50, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 50 + (51, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 51 + (52, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 52 + (53, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 53 + (54, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 54 + (55, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 55 + (56, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 56 + (57, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 57 + (58, 'လုပ်ငန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 58 + (59, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 59 + (60, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 60 + (61, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 61 + (62, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 62 + (63, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 63 + (64, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 64 + (65, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 65 + (66, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 66 + (67, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 67 + (68, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 68 + (69, 'လုပ်ငန်းအခြေအနေ ပြောင်းလဲပြီး'), -- Source record ID: 69 + (70, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 70 + (71, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 71 + (72, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 72 + (73, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 73 + (74, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး'), -- Source record ID: 74 + (75, 'စီမံကိန်း ခွဲဝေသတ်မှတ်ပြီး') -- Source record ID: 75 +) AS v("Id", "Value") +WHERE "notifications"."Id" = v."Id" + AND ("TitleMy" IS NULL OR btrim("TitleMy") = ''); + +-- notifications: BodyMy +UPDATE "notifications" +SET "BodyMy" = v."Value" +FROM ( + VALUES + (2, 'လုပ်ငန်း ''ပေါ်တယ်ပင်မစာမျက်နှာ'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 2 + (4, 'လုပ်ငန်း ''ပေါ်တယ်ပင်မစာမျက်နှာ'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 4 + (6, 'လုပ်ငန်း ''ပေါ်တယ်ပင်မစာမျက်နှာ'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 6 + (8, 'စစ်စတမ် အက်ဒမင်သည် လုပ်ငန်း ''ပေါ်တယ်ပင်မစာမျက်နှာ'' တွင် မှတ်ချက်ရေးသားခဲ့သည်'), -- Source record ID: 8 + (10, 'လုပ်ငန်း ''ပေါ်တယ်ပင်မစာမျက်နှာ'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 10 + (11, 'သင့်ထံသို့ ''စမ်းသပ်မှု'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 11 + (14, 'လုပ်ငန်း ''ဖောက်သည်အစီရင်ခံစာ'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 14 + (15, 'သင့်ထံသို့ ''ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 15 + (16, 'သင့်ထံသို့ ''ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 16 + (17, 'သင့်ထံသို့ ''ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 17 + (18, 'သင့်ထံသို့ ''ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 18 + (19, 'သင့်ထံသို့ ''ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 19 + (20, 'သင့်ထံသို့ ''ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 20 + (21, 'သင့်ထံသို့ ''မန္တလေး ကုန်ပစ္စည်းစာရင်း စနစ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 21 + (22, 'သင့်ထံသို့ ''မန္တလေး ကုန်ပစ္စည်းစာရင်း စနစ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 22 + (24, 'သင့်ထံသို့ ''မန္တလေး ကုန်ပစ္စည်းစာရင်း စနစ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 24 + (25, 'သင့်ထံသို့ ''မန္တလေး ကုန်ပစ္စည်းစာရင်း စနစ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 25 + (30, 'လုပ်ငန်း ''API ချိတ်ဆက်မှု'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 30 + (32, 'လုပ်ငန်း ''အရေးကြီး လုံခြုံရေးပြင်ဆင်ချက်'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 32 + (33, 'လုပ်ငန်း ''အရေးကြီး လုံခြုံရေးပြင်ဆင်ချက်'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 33 + (34, 'လုပ်ငန်း ''ရောင်းချသူစာရင်း တစ်ပြေးညီဖြစ်မှု အတည်ပြုခြင်း'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 34 + (35, 'လုပ်ငန်း ''ရောင်းချသူစာရင်း တစ်ပြေးညီဖြစ်မှု အတည်ပြုခြင်း'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 35 + (36, 'လုပ်ငန်း ''ရောင်းချသူစာရင်း တစ်ပြေးညီဖြစ်မှု အတည်ပြုခြင်း'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 36 + (41, 'စစ်စတမ် အက်ဒမင်သည် လုပ်ငန်း ''အရေးကြီး လုံခြုံရေးပြင်ဆင်ချက်'' တွင် မှတ်ချက်ရေးသားခဲ့သည်'), -- Source record ID: 41 + (42, 'စစ်စတမ် အက်ဒမင်သည် လုပ်ငန်း ''အရေးကြီး လုံခြုံရေးပြင်ဆင်ချက်'' တွင် မှတ်ချက်ရေးသားခဲ့သည်'), -- Source record ID: 42 + (43, 'လုပ်ငန်း ''အရေးကြီး လုံခြုံရေးပြင်ဆင်ချက်'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 43 + (44, 'လုပ်ငန်း ''အရေးကြီး လုံခြုံရေးပြင်ဆင်ချက်'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 44 + (45, 'လုပ်ငန်း ''ဖောက်သည်အစီရင်ခံစာ'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 45 + (46, 'လုပ်ငန်း ''လုပ်ဆောင်ချက်စစ်ဆေးမှတ်တမ်း စာမျက်နှာ'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 46 + (47, 'လုပ်ငန်း ''ကုန်ပစ္စည်းလက်ကျန် ဒက်ရှ်ဘုတ်ကတ်များ'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 47 + (48, 'လုပ်ငန်း ''API ချိတ်ဆက်မှု'' ၏ အခြေအနေကို ''ပြီးစီး'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 48 + (49, 'လုပ်ငန်း ''ပို့ဆောင်ရေး ငွေတောင်းခံလွှာ ခွဲခြမ်းဖတ်ရှုစနစ်'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 49 + (50, 'လုပ်ငန်း ''ပို့ဆောင်ရေး ငွေတောင်းခံလွှာ ခွဲခြမ်းဖတ်ရှုစနစ်'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 50 + (51, 'လုပ်ငန်း ''ပို့ဆောင်ရေး ငွေတောင်းခံလွှာ ခွဲခြမ်းဖတ်ရှုစနစ်'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''ပြီးစီး'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 51 + (52, 'လုပ်ငန်း ''ဘားကုဒ် ထောက်ပံ့ပို့ဆောင်ရေး အတည်ပြုလက်မှတ်'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''လုပ်ရန်'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 52 + (53, 'လုပ်ငန်း ''ဘားကုဒ် ထောက်ပံ့ပို့ဆောင်ရေး အတည်ပြုလက်မှတ်'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''လုပ်ရန်'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 53 + (54, 'လုပ်ငန်း ''ဘားကုဒ် ထောက်ပံ့ပို့ဆောင်ရေး အတည်ပြုလက်မှတ်'' ၏ အခြေအနေကို ''လုပ်ဆောင်နေဆဲ'' မှ ''လုပ်ရန်'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 54 + (55, 'လုပ်ငန်း ''ဘားကုဒ် ထောက်ပံ့ပို့ဆောင်ရေး အတည်ပြုလက်မှတ်'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 55 + (56, 'လုပ်ငန်း ''ဘားကုဒ် ထောက်ပံ့ပို့ဆောင်ရေး အတည်ပြုလက်မှတ်'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 56 + (57, 'လုပ်ငန်း ''ဘားကုဒ် ထောက်ပံ့ပို့ဆောင်ရေး အတည်ပြုလက်မှတ်'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 57 + (58, 'သင့်ထံသို့ လုပ်ငန်းအသစ်တစ်ခုကို ခွဲဝေသတ်မှတ်ထားသည်: အရေးကြီး လုံခြုံရေးပြင်ဆင်ချက်'), -- Source record ID: 58 + (59, 'သင့်ထံသို့ ''ရန်ကုန် ဖောက်သည်ဝန်ဆောင်မှု ပေါ်တယ်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 59 + (60, 'သင့်ထံသို့ ''အဝတ်အထည်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 60 + (61, 'သင့်ထံသို့ ''အဝတ်အထည်'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 61 + (62, 'သင့်ထံသို့ ''ယနေ့ငွေစျေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 62 + (63, 'သင့်ထံသို့ ''ယနေ့ငွေစျေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 63 + (64, 'သင့်ထံသို့ ''ယနေ့ငွေစျေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 64 + (65, 'သင့်ထံသို့ ''ယနေ့ငွေစျေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 65 + (66, 'လုပ်ငန်း ''ခွင့်မူဝါဒဇယား ပြင်ဆင်မှု'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 66 + (67, 'လုပ်ငန်း ''ခွင့်မူဝါဒဇယား ပြင်ဆင်မှု'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 67 + (68, 'လုပ်ငန်း ''ခွင့်မူဝါဒဇယား ပြင်ဆင်မှု'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 68 + (69, 'လုပ်ငန်း ''စျေးနှုန်းတွက်ချက်မှု'' ၏ အခြေအနေကို ''လုပ်ရန်'' မှ ''လုပ်ဆောင်နေဆဲ'' သို့ ပြောင်းလဲလိုက်သည်'), -- Source record ID: 69 + (70, 'သင့်ထံသို့ ''ယနေ့ငွေစျေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 70 + (71, 'သင့်ထံသို့ ''ဆိုင်ကယ်ရောင်းဝယ်ရေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 71 + (72, 'သင့်ထံသို့ ''ဆိုင်ကယ်ရောင်းဝယ်ရေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 72 + (73, 'သင့်ထံသို့ ''ဆိုင်ကယ်ရောင်းဝယ်ရေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 73 + (74, 'သင့်ထံသို့ ''ဆိုင်ကယ်ရောင်းဝယ်ရေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်'), -- Source record ID: 74 + (75, 'သင့်ထံသို့ ''ဆိုင်ကယ်ရောင်းဝယ်ရေး'' စီမံကိန်းကို ခွဲဝေသတ်မှတ်ထားသည်') -- Source record ID: 75 +) AS v("Id", "Value") +WHERE "notifications"."Id" = v."Id" + AND ("BodyMy" IS NULL OR btrim("BodyMy") = ''); + +COMMIT; diff --git a/database/scripts/backfill_burmese_review_sample.sql b/database/scripts/backfill_burmese_review_sample.sql new file mode 100644 index 0000000000000000000000000000000000000000..b200a66b36dfc696a2bbfe924888b72d025b2bd6 --- /dev/null +++ b/database/scripts/backfill_burmese_review_sample.sql @@ -0,0 +1,120 @@ +-- Burmese translation review sample generated from the original English CSV exports. +-- Scope: 10 Projects, 15 Tasks, 5 Issues. +-- This is a review sample only. It was not executed and does not replace the prior SQL file. + +BEGIN; + +-- Projects: NameMy +UPDATE "Projects" +SET "NameMy" = v."Value" +FROM ( + VALUES + (1, 'ရန်ကုန် ဖောက်သည်ပေါ်တယ်'), -- Source record ID: 1 + (2, 'မန္တလေး ကုန်ပစ္စည်းစာရင်းစနစ်'), -- Source record ID: 2 + (3, 'နေပြည်တော် HR ခြေရာခံစနစ်'), -- Source record ID: 3 + (4, 'ပဲခူး ဝယ်ယူရေးစီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 4 + (5, 'တောင်ကြီး အကူအညီပေးရေးစနစ်'), -- Source record ID: 5 + (6, 'မော်လမြိုင် ထောက်ပံ့ရေးစီမံခန့်ခွဲမှုစနစ်'), -- Source record ID: 6 + (7, 'ပုသိမ် ပင်လယ်စာရောင်းဝယ်ရေးပေါ်တယ်'), -- Source record ID: 7 + (8, 'မုံရွာ ငွေတောင်းခံမှုစနစ်'), -- Source record ID: 8 + (9, 'စစ်တွေ ဆိပ်ကမ်း ပင်မစာမျက်နှာ'), -- Source record ID: 9 + (10, 'ပြင်ဦးလွင် စမတ်စိုက်ပျိုးရေးစနစ်') -- Source record ID: 10 +) AS v("Id", "Value") +WHERE "Projects"."Id" = v."Id" + AND ("NameMy" IS NULL OR btrim("NameMy") = ''); + +-- Projects: DescriptionMy +UPDATE "Projects" +SET "DescriptionMy" = v."Value" +FROM ( + VALUES + (1, 'ရန်ကုန်ရှိ ရုံးခွဲများအတွက် ဖောက်သည်ဝန်ဆောင်မှုပေါ်တယ်'), -- Source record ID: 1 + (2, 'မန္တလေးအဖွဲ့အတွက် ဂိုဒေါင်နှင့် ကုန်ပစ္စည်းများကို ခြေရာခံသည့်စနစ်'), -- Source record ID: 2 + (3, 'HR လုပ်ငန်းစဉ်များနှင့် ခွင့်ရက်များကို ခြေရာခံသည့်စနစ်'), -- Source record ID: 3 + (4, 'ဝယ်ယူရန်တောင်းဆိုမှုများနှင့် အတည်ပြုခြင်းလုပ်ငန်းစဉ်'), -- Source record ID: 4 + (5, 'အကူအညီတောင်းခံမှုနှင့် ပြဿနာများကို ခြေရာခံသည့် ပင်မစာမျက်နှာ'), -- Source record ID: 5 + (6, 'ထောက်ပံ့ရေးကွင်းဆက်ကို ခြေရာခံသည့်စနစ်'), -- Source record ID: 6 + (7, 'လုပ်ငန်းများအကြား ပင်လယ်စာရောင်းဝယ်ရန် အွန်လိုင်းစျေးကွက်ပလက်ဖောင်း'), -- Source record ID: 7 + (8, 'အသုံးဝင်မှုဝန်ဆောင်ခများကို အလိုအလျောက် ငွေတောင်းခံသည့်စနစ်'), -- Source record ID: 8 + (9, 'သင်္ဘောနှင့် ကုန်ပစ္စည်းများကို ခြေရာခံရန် UI'), -- Source record ID: 9 + (10, 'စမတ်စိုက်ပျိုးရေးဆိုင်ရာ အချက်အလက်ခွဲခြမ်းစိတ်ဖြာမှု ပင်မစာမျက်နှာ') -- Source record ID: 10 +) AS v("Id", "Value") +WHERE "Projects"."Id" = v."Id" + AND ("DescriptionMy" IS NULL OR btrim("DescriptionMy") = ''); + +-- Tasks: TitleMy +UPDATE "Tasks" +SET "TitleMy" = v."Value" +FROM ( + VALUES + (1, 'ပေါ်တယ်ပင်မစာမျက်နှာ ပြုပြင်ခြင်း'), -- Source record ID: 1 + (2, 'အကောင့်ဝင်ခြင်းနှင့် အခန်းကဏ္ဍအလိုက် လမ်းကြောင်းသတ်မှတ်ခြင်း'), -- Source record ID: 2 + (3, 'ဖောက်သည်ကိုယ်ရေးအချက်အလက် ဖြည့်သွင်းပုံစံ'), -- Source record ID: 3 + (4, 'ငွေပေးချေမှုစာမျက်နှာ ပြုပြင်ရှင်းလင်းခြင်း'), -- Source record ID: 4 + (5, 'အသိပေးချက်ဗဟိုစနစ်'), -- Source record ID: 5 + (6, 'ဖောက်သည်စာရင်း ထုတ်ယူခြင်း'), -- Source record ID: 6 + (7, 'စစ်ဆေးမှတ်တမ်းစာမျက်နှာ'), -- Source record ID: 7 + (8, 'အသွင်အပြင် နောက်ဆုံးပြင်ဆင်ခြင်း'), -- Source record ID: 8 + (9, 'ကုန်ပစ္စည်းလက်ကျန် ပင်မစာမျက်နှာကတ်များ'), -- Source record ID: 9 + (10, 'ဂိုဒေါင်ပစ္စည်း ရှာဖွေခြင်း'), -- Source record ID: 10 + (11, 'ကုန်ပစ္စည်းလက်ကျန်နည်းပါးမှု သတိပေးချက် ပြင်ဆင်ခြင်း'), -- Source record ID: 11 + (12, 'ကုန်ပစ္စည်းလက်ခံ ဖြည့်သွင်းပုံစံ'), -- Source record ID: 12 + (13, 'ကုန်ပစ္စည်း ဘားကုဒ်စာမျက်နှာ'), -- Source record ID: 13 + (14, 'ကုန်ပစ္စည်းစာရင်း အစီရင်ခံစာ ထုတ်ယူခြင်း'), -- Source record ID: 14 + (15, 'ကုန်ပစ္စည်းလက်ကျန် ပြင်ဆင်မှု စစ်ဆေးခြင်း') -- Source record ID: 15 +) AS v("Id", "Value") +WHERE "Tasks"."Id" = v."Id" + AND ("TitleMy" IS NULL OR btrim("TitleMy") = ''); + +-- Tasks: DescriptionMy +UPDATE "Tasks" +SET "DescriptionMy" = v."Value" +FROM ( + VALUES + (1, 'ရန်ကုန်ဖောက်သည်ပေါ်တယ်၏ ပင်မစာမျက်နှာကို ပြုပြင်ရှင်းလင်းခြင်း'), -- Source record ID: 1 + (2, 'အကောင့်ဝင်ပြီးနောက် သက်ဆိုင်ရာစာမျက်နှာသို့ ပြန်လည်ညွှန်ပို့သည့် စည်းမျဉ်းများ'), -- Source record ID: 2 + (3, 'ကိုယ်ရေးအချက်အလက်များကို ပြင်ဆင်ခြင်းနှင့် မှန်ကန်မှု စစ်ဆေးခြင်း'), -- Source record ID: 3 + (4, 'ငွေပေးချေမှုအပိုင်းရှိ UI နှင့် စစ်ထုတ်မှုများကို ပြုပြင်ရှင်းလင်းခြင်း'), -- Source record ID: 4 + (5, 'အသိပေးချက်စာရင်းနှင့် မဖတ်ရသေးကြောင်းပြသသည့် အမှတ်အသား'), -- Source record ID: 5 + (6, 'ဖောက်သည်အချက်အလက်များကို CSV / Excel ဖိုင်အဖြစ် ထုတ်ယူခြင်း'), -- Source record ID: 6 + (7, 'လုပ်ဆောင်ချက်မှတ်တမ်းနှင့် ဖြစ်စဉ်မှတ်တမ်းစာမျက်နှာ'), -- Source record ID: 7 + (8, 'နေရာလွတ်နှင့် အရောင်များကို နောက်ဆုံးအဆင့် ပြုပြင်မွမ်းမံခြင်း'), -- Source record ID: 8 + (9, 'ဂိုဒေါင်အကျဉ်းချုပ်ကတ်များနှင့် အရေအတွက်ပြကိန်းများ'), -- Source record ID: 9 + (10, 'ကုန်ပစ္စည်းများကို မြန်ဆန်စွာ ရှာဖွေပြီး စစ်ထုတ်နိုင်ရန်'), -- Source record ID: 10 + (11, 'လက်ကျန်နည်းပါးသည့်အခါ သတိပေးအမှတ်အသားများ ပြသခြင်း'), -- Source record ID: 11 + (12, 'လက်ခံရရှိသော ကုန်ပစ္စည်းများကို မှတ်တမ်းတင်ရန် ဖြည့်သွင်းပုံစံ'), -- Source record ID: 12 + (13, 'ဘားကုဒ်ဖြင့် ရှာဖွေခြင်းနှင့် ကုန်ပစ္စည်းအသေးစိတ်စာမျက်နှာ'), -- Source record ID: 13 + (14, 'စီမံခန့်ခွဲမှုအတွက် ကုန်ပစ္စည်းလက်ကျန်အကျဉ်းချုပ်ကို ထုတ်ယူခြင်း'), -- Source record ID: 14 + (15, 'ပြင်ဆင်ပြောင်းလဲထားသည့် မှတ်တမ်းများကို ပြန်လည်သုံးသပ်ခြင်း') -- Source record ID: 15 +) AS v("Id", "Value") +WHERE "Tasks"."Id" = v."Id" + AND ("DescriptionMy" IS NULL OR btrim("DescriptionMy") = ''); + +-- Issues: TitleMy +UPDATE "Issues" +SET "TitleMy" = v."Value" +FROM ( + VALUES + (1, 'API စမ်းသပ်ခြင်း'), -- Source record ID: 1 + (2, 'ကွန်ရက်လုံခြုံရေး တားဆီးကာများကို စစ်ဆေးခြင်း'), -- Source record ID: 2 + (3, 'Yangon Retail Upgrade စီမံကိန်း၏ လက်လီငွေရှင်းလုပ်ငန်းစဉ်အတွက် အသုံးပြုသူလိုအပ်ချက်များကို ပြန်လည်ပြင်ဆင်ခြင်း'), -- Source record ID: 3 + (4, 'Yangon Retail စီမံကိန်းအတွက် POS စနစ်ကို ပေါင်းစည်းအကောင်အထည်ဖော်ခြင်း'), -- Source record ID: 4 + (5, 'Yangon Retail စီမံကိန်း၏ ငွေကြေးအမျိုးအစားစုံ ငွေရှင်းလုပ်ငန်းစဉ်ကို အရည်အသွေးစစ်ဆေးခြင်း') -- Source record ID: 5 +) AS v("Id", "Value") +WHERE "Issues"."Id" = v."Id" + AND ("TitleMy" IS NULL OR btrim("TitleMy") = ''); + +-- Issues: DescriptionMy +UPDATE "Issues" +SET "DescriptionMy" = v."Value" +FROM ( + VALUES + (1, 'API စမ်းသပ်မှု'), -- Source record ID: 1 + (3, 'လိုအပ်ချက်ဆွေးနွေးပွဲအပြီး ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 3 + (4, 'အကောင်အထည်ဖော်ရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်'), -- Source record ID: 4 + (5, 'အတည်ပြုစစ်ဆေးရန် ဆက်လက်လုပ်ဆောင်ရမည့်အလုပ်') -- Source record ID: 5 +) AS v("Id", "Value") +WHERE "Issues"."Id" = v."Id" + AND ("DescriptionMy" IS NULL OR btrim("DescriptionMy") = ''); + +COMMIT; diff --git a/docs/openrouter-translation.md b/docs/openrouter-translation.md new file mode 100644 index 0000000000000000000000000000000000000000..db858fb6e80db08e072dcf695ab83bcc16384260 --- /dev/null +++ b/docs/openrouter-translation.md @@ -0,0 +1,39 @@ +# OpenRouter translation provider + +The Web API uses `OpenRouterContentTranslationService` when +`Translation:Provider` is `OpenRouter`. The Blazor client calls the Web API +and never receives or stores the OpenRouter API key. + +## Local development with user-secrets + +Run these commands from the solution directory. Replace `YOUR_KEY` locally; +never commit the value. + +```powershell +dotnet user-secrets set "Translation:Provider" "OpenRouter" --project TaskTrackingSystem.WebApi +dotnet user-secrets set "Translation:Endpoint" "https://openrouter.ai/api/v1/chat/completions" --project TaskTrackingSystem.WebApi +dotnet user-secrets set "Translation:Model" "openrouter/free" --project TaskTrackingSystem.WebApi +dotnet user-secrets set "Translation:ApiKey" "YOUR_KEY" --project TaskTrackingSystem.WebApi +``` + +Restart the Web API after changing secrets. + +## Deployment environment variables + +Configure these variables on the Web API host only: + +```text +Translation__Provider=OpenRouter +Translation__Endpoint=https://openrouter.ai/api/v1/chat/completions +Translation__Model=openrouter/free +Translation__ApiKey=YOUR_KEY +``` + +The key belongs in `Translation:ApiKey` through user-secrets or the +deployment environment. It must not be placed in the Blazor WebApp +configuration, browser storage, source control, or client-side code. + +The provider sends `Authorization: Bearer {ApiKey}`, `HTTP-Referer: +https://huggingface.co`, and `X-Title: TaskTrackingSystem`. It parses +`choices[0].message.content` and returns controlled failures for invalid keys, +rate limits, empty responses, timeouts, and network errors. diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..506142c3f95d2b8fdd26a56b054ea8eba614bb9b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,66 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + server { + listen 7860; + server_name _; + client_max_body_size 50m; + + location /api/ { + proxy_pass http://127.0.0.1:5001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + location /hubs/notifications { + proxy_pass http://127.0.0.1:5001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + location /health { + proxy_pass http://127.0.0.1:5000/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + } +} diff --git a/start.sh b/start.sh new file mode 100644 index 0000000000000000000000000000000000000000..3b643b0078a6c3dc1a78311049abd563d75752a8 --- /dev/null +++ b/start.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${WebApi__BaseUrl:=http://127.0.0.1:5001/api/}" +export WebApi__BaseUrl + +dotnet /app/api/TaskTrackingSystem.WebApi.dll --contentRoot /app/api --urls http://127.0.0.1:5001 & +api_pid=$! + +dotnet /app/webapp/TaskTrackingSystem.WebApp.dll --contentRoot /app/webapp --urls http://127.0.0.1:5000 & +webapp_pid=$! + +term_handler() { + kill "$api_pid" "$webapp_pid" 2>/dev/null || true + exit 0 +} + +trap term_handler SIGTERM SIGINT + +nginx -g 'daemon off;' & +nginx_pid=$! + +wait -n "$api_pid" "$webapp_pid" "$nginx_pid" +term_handler