redesign the frontend and the backend
This commit is contained in:
parent
34f87c6390
commit
246288a3de
83 changed files with 24467 additions and 34278 deletions
|
|
@ -36,8 +36,14 @@ namespace Data
|
||||||
public DbSet<ResponseAnswer> ResponseAnswers { get; set; }
|
public DbSet<ResponseAnswer> ResponseAnswers { get; set; }
|
||||||
|
|
||||||
public DbSet<SentNewsletterEamil> SentNewsletterEamils { get; set; }
|
public DbSet<SentNewsletterEamil> SentNewsletterEamils { get; set; }
|
||||||
|
public DbSet<ResponseAnalysis> ResponseAnalyses { get; set; }
|
||||||
|
public DbSet<QuestionnaireAnalysisSnapshot> QuestionnaireAnalysisSnapshots { get; set; }
|
||||||
|
|
||||||
|
public DbSet<CaseNote> CaseNotes { get; set; }
|
||||||
|
public DbSet<CaseStatusEntry> CaseStatusEntries { get; set; }
|
||||||
|
public DbSet<ActionPlan> ActionPlans { get; set; }
|
||||||
|
|
||||||
|
public DbSet<UserTrajectoryCache> UserTrajectoryCaches { get; set; }
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<FooterSocialMedia>()
|
modelBuilder.Entity<FooterSocialMedia>()
|
||||||
|
|
@ -87,6 +93,30 @@ namespace Data
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
|
||||||
|
modelBuilder.Entity<ResponseAnalysis>()
|
||||||
|
.HasOne(ra => ra.Response)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(ra => ra.ResponseId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ResponseAnalysis>()
|
||||||
|
.HasOne(ra => ra.Question)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(ra => ra.QuestionId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<QuestionnaireAnalysisSnapshot>()
|
||||||
|
.HasOne(s => s.Questionnaire)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.QuestionnaireId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
|
||||||
|
modelBuilder.Entity<ResponseAnswer>()
|
||||||
|
.HasOne(ra => ra.Answer)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(ra => ra.AnswerId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
60
Model/ActionPlan.cs
Normal file
60
Model/ActionPlan.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Model
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Concrete action plans for contacting/intervening with the respondent.
|
||||||
|
/// </summary>
|
||||||
|
public class ActionPlan
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int ResponseId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("ResponseId")]
|
||||||
|
public Response? Response { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type: ImmediateContact, CounselingReferral, WorkplaceAccommodation, FollowUpAssessment, ManagementAlert
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string ActionType { get; set; } = "ImmediateContact";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Priority: Urgent, High, Normal, Low
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string Priority { get; set; } = "Normal";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status: Pending, InProgress, Completed, Cancelled
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = "Pending";
|
||||||
|
|
||||||
|
public string? AssignedTo { get; set; }
|
||||||
|
|
||||||
|
public string? AssignedToEmail { get; set; }
|
||||||
|
|
||||||
|
public DateTime? ScheduledDate { get; set; }
|
||||||
|
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
|
||||||
|
public string? CompletionNotes { get; set; }
|
||||||
|
|
||||||
|
public string CreatedByName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? CreatedByEmail { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Model/CaseNote.cs
Normal file
36
Model/CaseNote.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Model
|
||||||
|
{
|
||||||
|
public class CaseNote
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int ResponseId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("ResponseId")]
|
||||||
|
public Response? Response { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string AuthorName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? AuthorEmail { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string NoteText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category: General, Risk, Intervention, FollowUp, Resolution
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; set; } = "General";
|
||||||
|
|
||||||
|
public bool IsConfidential { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Model/CaseStatusEntry.cs
Normal file
46
Model/CaseStatusEntry.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Model
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks the status workflow: New → UnderReview → InterventionScheduled → Resolved
|
||||||
|
/// Each entry is a log of a status change (full audit trail)
|
||||||
|
/// </summary>
|
||||||
|
public class CaseStatusEntry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int ResponseId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("ResponseId")]
|
||||||
|
public Response? Response { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public CaseStatusType Status { get; set; } = CaseStatusType.New;
|
||||||
|
|
||||||
|
public string? ChangedByName { get; set; }
|
||||||
|
|
||||||
|
public string? ChangedByEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional reason/note for the status change
|
||||||
|
/// </summary>
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
|
||||||
|
public DateTime ChangedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CaseStatusType
|
||||||
|
{
|
||||||
|
New = 0,
|
||||||
|
UnderReview = 1,
|
||||||
|
InterventionScheduled = 2,
|
||||||
|
InProgress = 3,
|
||||||
|
FollowUp = 4,
|
||||||
|
Resolved = 5,
|
||||||
|
Closed = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Model/QuestionnaireAnalysisSnapshot.cs
Normal file
45
Model/QuestionnaireAnalysisSnapshot.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Model/QuestionnaireAnalysisSnapshot.cs
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Model
|
||||||
|
{
|
||||||
|
public class QuestionnaireAnalysisSnapshot
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int QuestionnaireId { get; set; }
|
||||||
|
|
||||||
|
// Counts
|
||||||
|
public int TotalResponses { get; set; }
|
||||||
|
public int AnalyzedResponses { get; set; }
|
||||||
|
|
||||||
|
// Aggregated sentiment averages
|
||||||
|
public double OverallPositiveSentiment { get; set; }
|
||||||
|
public double OverallNegativeSentiment { get; set; }
|
||||||
|
public double OverallNeutralSentiment { get; set; }
|
||||||
|
|
||||||
|
// Risk distribution counts
|
||||||
|
public int LowRiskCount { get; set; }
|
||||||
|
public int ModerateRiskCount { get; set; }
|
||||||
|
public int HighRiskCount { get; set; }
|
||||||
|
public int CriticalRiskCount { get; set; }
|
||||||
|
|
||||||
|
// Executive summary (generated by Claude once)
|
||||||
|
public string ExecutiveSummary { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Complex data as JSON
|
||||||
|
public string TopWorkplaceIssuesJson { get; set; } = "[]";
|
||||||
|
public string MostCommonKeyPhrasesJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
[ForeignKey("QuestionnaireId")]
|
||||||
|
public virtual Questionnaire? Questionnaire { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,5 +20,10 @@ namespace Model
|
||||||
|
|
||||||
|
|
||||||
public List<ResponseDetail> ResponseDetails { get; set; } = new List<ResponseDetail>();
|
public List<ResponseDetail> ResponseDetails { get; set; } = new List<ResponseDetail>();
|
||||||
|
|
||||||
|
|
||||||
|
public List<CaseNote> CaseNotes { get; set; } = new List<CaseNote>();
|
||||||
|
public List<CaseStatusEntry> StatusHistory { get; set; } = new List<CaseStatusEntry>();
|
||||||
|
public List<ActionPlan> ActionPlans { get; set; } = new List<ActionPlan>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
Model/ResponseAnalysis.cs
Normal file
58
Model/ResponseAnalysis.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Model/ResponseAnalysis.cs
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Model
|
||||||
|
{
|
||||||
|
public class ResponseAnalysis
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// Links to existing tables
|
||||||
|
[Required]
|
||||||
|
public int ResponseId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public int QuestionId { get; set; }
|
||||||
|
|
||||||
|
// Question context (not PII)
|
||||||
|
public string QuestionText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Anonymized only — never store raw user text here
|
||||||
|
public string AnonymizedText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Sentiment (from Claude)
|
||||||
|
public string SentimentLabel { get; set; } = "Neutral"; // Positive, Negative, Neutral, Mixed
|
||||||
|
public double SentimentConfidence { get; set; }
|
||||||
|
public double PositiveScore { get; set; }
|
||||||
|
public double NegativeScore { get; set; }
|
||||||
|
public double NeutralScore { get; set; }
|
||||||
|
|
||||||
|
// Risk Assessment (from Claude)
|
||||||
|
public string RiskLevel { get; set; } = "Low"; // Low, Moderate, High, Critical
|
||||||
|
public double RiskScore { get; set; }
|
||||||
|
public bool RequiresImmediateAttention { get; set; }
|
||||||
|
public string RecommendedAction { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Complex data stored as JSON strings
|
||||||
|
public string RiskIndicatorsJson { get; set; } = "[]";
|
||||||
|
public string ProtectiveFactorsJson { get; set; } = "[]";
|
||||||
|
public string KeyPhrasesJson { get; set; } = "[]";
|
||||||
|
public string WorkplaceFactorsJson { get; set; } = "[]";
|
||||||
|
public string EmotionalIndicatorsJson { get; set; } = "[]";
|
||||||
|
public string InsightsJson { get; set; } = "[]"; // WorkplaceInsight list
|
||||||
|
public string CategoriesJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
[ForeignKey("ResponseId")]
|
||||||
|
public virtual Response? Response { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("QuestionId")]
|
||||||
|
public virtual Question? Question { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,5 +15,8 @@ namespace Model
|
||||||
[ForeignKey("ResponseDetailId")]
|
[ForeignKey("ResponseDetailId")]
|
||||||
public ResponseDetail? ResponseDetail { get; set; }
|
public ResponseDetail? ResponseDetail { get; set; }
|
||||||
public int AnswerId { get; set; }
|
public int AnswerId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("AnswerId")]
|
||||||
|
public Answer? Answer { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
Model/UserTrajectoryCache.cs
Normal file
53
Model/UserTrajectoryCache.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Model
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Caches AI trajectory analysis results per user.
|
||||||
|
/// Re-analyzed only when new responses are submitted.
|
||||||
|
/// </summary>
|
||||||
|
public class UserTrajectoryCache
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's email — used as the lookup key
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string UserEmail { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many responses were included in this analysis
|
||||||
|
/// </summary>
|
||||||
|
public int AnalyzedResponseCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date of the most recent response that was analyzed
|
||||||
|
/// Used to detect new responses
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastResponseDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full trajectory analysis result stored as JSON
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string TrajectoryJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A shorter summary that Claude can use as context
|
||||||
|
/// for incremental updates (when only new responses are sent)
|
||||||
|
/// </summary>
|
||||||
|
public string? PreviousSummary { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
// Services/AIViewModel/AIAnalysisViewModels.cs
|
// Services/AIViewModel/AIAnalysisViewModels.cs
|
||||||
namespace Services.AIViewModel
|
namespace Services.AIViewModel
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Risk severity levels for mental health assessment.
|
||||||
|
/// </summary>
|
||||||
public enum RiskLevel
|
public enum RiskLevel
|
||||||
{
|
{
|
||||||
Low = 1,
|
Low = 1,
|
||||||
|
|
@ -9,9 +12,12 @@ namespace Services.AIViewModel
|
||||||
Critical = 4
|
Critical = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sentiment analysis output with confidence breakdown.
|
||||||
|
/// </summary>
|
||||||
public class SentimentAnalysisResult
|
public class SentimentAnalysisResult
|
||||||
{
|
{
|
||||||
public string Sentiment { get; set; } = string.Empty; // Positive, Negative, Neutral
|
public string Sentiment { get; set; } = string.Empty;
|
||||||
public double ConfidenceScore { get; set; }
|
public double ConfidenceScore { get; set; }
|
||||||
public double PositiveScore { get; set; }
|
public double PositiveScore { get; set; }
|
||||||
public double NegativeScore { get; set; }
|
public double NegativeScore { get; set; }
|
||||||
|
|
@ -19,55 +25,67 @@ namespace Services.AIViewModel
|
||||||
public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow;
|
public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Key phrases extracted with workplace and emotional categorization.
|
||||||
|
/// </summary>
|
||||||
public class KeyPhrasesResult
|
public class KeyPhrasesResult
|
||||||
{
|
{
|
||||||
public List<string> KeyPhrases { get; set; } = new List<string>();
|
public List<string> KeyPhrases { get; set; } = new();
|
||||||
public List<string> WorkplaceFactors { get; set; } = new List<string>();
|
public List<string> WorkplaceFactors { get; set; } = new();
|
||||||
public List<string> EmotionalIndicators { get; set; } = new List<string>();
|
public List<string> EmotionalIndicators { get; set; } = new();
|
||||||
public DateTime ExtractedAt { get; set; } = DateTime.UtcNow;
|
public DateTime ExtractedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mental health risk assessment with indicators and recommendations.
|
||||||
|
/// </summary>
|
||||||
public class MentalHealthRiskAssessment
|
public class MentalHealthRiskAssessment
|
||||||
{
|
{
|
||||||
public RiskLevel RiskLevel { get; set; }
|
public RiskLevel RiskLevel { get; set; }
|
||||||
public double RiskScore { get; set; } // 0-1 scale
|
public double RiskScore { get; set; }
|
||||||
public List<string> RiskIndicators { get; set; } = new List<string>();
|
public List<string> RiskIndicators { get; set; } = new();
|
||||||
public List<string> ProtectiveFactors { get; set; } = new List<string>();
|
public List<string> ProtectiveFactors { get; set; } = new();
|
||||||
public bool RequiresImmediateAttention { get; set; }
|
public bool RequiresImmediateAttention { get; set; }
|
||||||
public string RecommendedAction { get; set; } = string.Empty;
|
public string RecommendedAction { get; set; } = string.Empty;
|
||||||
public DateTime AssessedAt { get; set; } = DateTime.UtcNow;
|
public DateTime AssessedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Workplace insight with categorized issue and intervention recommendation.
|
||||||
|
/// </summary>
|
||||||
public class WorkplaceInsight
|
public class WorkplaceInsight
|
||||||
{
|
{
|
||||||
public string Category { get; set; } = string.Empty; // e.g., "Work-Life Balance", "Management", "Workload"
|
public string Category { get; set; } = string.Empty;
|
||||||
public string Issue { get; set; } = string.Empty;
|
public string Issue { get; set; } = string.Empty;
|
||||||
public string RecommendedIntervention { get; set; } = string.Empty;
|
public string RecommendedIntervention { get; set; } = string.Empty;
|
||||||
public int Priority { get; set; } // 1-5 scale
|
public int Priority { get; set; }
|
||||||
public List<string> AffectedAreas { get; set; } = new List<string>();
|
public List<string> AffectedAreas { get; set; } = new();
|
||||||
public DateTime IdentifiedAt { get; set; } = DateTime.UtcNow;
|
public DateTime IdentifiedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Complete analysis result for a single response — aggregates all AI outputs.
|
||||||
|
/// </summary>
|
||||||
public class ResponseAnalysisResult
|
public class ResponseAnalysisResult
|
||||||
{
|
{
|
||||||
public int ResponseId { get; set; }
|
public int ResponseId { get; set; }
|
||||||
public int QuestionId { get; set; }
|
public int QuestionId { get; set; }
|
||||||
public string QuestionText { get; set; } = string.Empty;
|
public string QuestionText { get; set; } = string.Empty;
|
||||||
public string ResponseText { get; set; } = string.Empty;
|
public string ResponseText { get; set; } = string.Empty;
|
||||||
public string AnonymizedResponseText { get; set; } = string.Empty; // PII removed
|
public string AnonymizedResponseText { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Azure Language Service Results
|
|
||||||
public SentimentAnalysisResult? SentimentAnalysis { get; set; }
|
public SentimentAnalysisResult? SentimentAnalysis { get; set; }
|
||||||
public KeyPhrasesResult? KeyPhrases { get; set; }
|
public KeyPhrasesResult? KeyPhrases { get; set; }
|
||||||
|
|
||||||
// Azure OpenAI Results
|
|
||||||
public MentalHealthRiskAssessment? RiskAssessment { get; set; }
|
public MentalHealthRiskAssessment? RiskAssessment { get; set; }
|
||||||
public List<WorkplaceInsight> Insights { get; set; } = new List<WorkplaceInsight>();
|
public List<WorkplaceInsight> Insights { get; set; } = new();
|
||||||
|
|
||||||
public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow;
|
public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow;
|
||||||
public bool IsAnalysisComplete { get; set; } = false;
|
public bool IsAnalysisComplete { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregated analysis overview for an entire questionnaire.
|
||||||
|
/// </summary>
|
||||||
public class QuestionnaireAnalysisOverview
|
public class QuestionnaireAnalysisOverview
|
||||||
{
|
{
|
||||||
public int QuestionnaireId { get; set; }
|
public int QuestionnaireId { get; set; }
|
||||||
|
|
@ -75,25 +93,28 @@ namespace Services.AIViewModel
|
||||||
public int TotalResponses { get; set; }
|
public int TotalResponses { get; set; }
|
||||||
public int AnalyzedResponses { get; set; }
|
public int AnalyzedResponses { get; set; }
|
||||||
|
|
||||||
// Overall Statistics
|
// Sentiment distribution (0.0–1.0)
|
||||||
public double OverallPositiveSentiment { get; set; }
|
public double OverallPositiveSentiment { get; set; }
|
||||||
public double OverallNegativeSentiment { get; set; }
|
public double OverallNegativeSentiment { get; set; }
|
||||||
public double OverallNeutralSentiment { get; set; }
|
public double OverallNeutralSentiment { get; set; }
|
||||||
|
|
||||||
// Risk Distribution
|
// Risk distribution counts
|
||||||
public int LowRiskResponses { get; set; }
|
public int LowRiskResponses { get; set; }
|
||||||
public int ModerateRiskResponses { get; set; }
|
public int ModerateRiskResponses { get; set; }
|
||||||
public int HighRiskResponses { get; set; }
|
public int HighRiskResponses { get; set; }
|
||||||
public int CriticalRiskResponses { get; set; }
|
public int CriticalRiskResponses { get; set; }
|
||||||
|
|
||||||
// Top Issues
|
// Insights
|
||||||
public List<WorkplaceInsight> TopWorkplaceIssues { get; set; } = new List<WorkplaceInsight>();
|
public List<WorkplaceInsight> TopWorkplaceIssues { get; set; } = new();
|
||||||
public List<string> MostCommonKeyPhrases { get; set; } = new List<string>();
|
public List<string> MostCommonKeyPhrases { get; set; } = new();
|
||||||
|
|
||||||
public DateTime LastAnalyzedAt { get; set; }
|
public DateTime LastAnalyzedAt { get; set; }
|
||||||
public string ExecutiveSummary { get; set; } = string.Empty;
|
public string ExecutiveSummary { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input request for analysis pipeline.
|
||||||
|
/// </summary>
|
||||||
public class AnalysisRequest
|
public class AnalysisRequest
|
||||||
{
|
{
|
||||||
public int ResponseId { get; set; }
|
public int ResponseId { get; set; }
|
||||||
|
|
@ -106,4 +127,152 @@ namespace Services.AIViewModel
|
||||||
public bool IncludeRiskAssessment { get; set; } = true;
|
public bool IncludeRiskAssessment { get; set; } = true;
|
||||||
public bool IncludeWorkplaceInsights { get; set; } = true;
|
public bool IncludeWorkplaceInsights { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UserTrajectoryAnalysis
|
||||||
|
{
|
||||||
|
// ── Overall Trajectory ──
|
||||||
|
/// <summary>
|
||||||
|
/// "Improving", "Stable", "Declining", "Fluctuating", "Initial" (single response)
|
||||||
|
/// </summary>
|
||||||
|
public string TrajectoryDirection { get; set; } = "Initial";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overall wellness score 0-100
|
||||||
|
/// </summary>
|
||||||
|
public int TrajectoryScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Change from first to latest response (e.g., +15 or -20). 0 if single response.
|
||||||
|
/// </summary>
|
||||||
|
public int ScoreChange { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Low", "Moderate", "High", "Critical"
|
||||||
|
/// </summary>
|
||||||
|
public string OverallRiskLevel { get; set; } = "Low";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2-3 sentence overview
|
||||||
|
/// </summary>
|
||||||
|
public string ExecutiveSummary { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Longer detailed analysis paragraph
|
||||||
|
/// </summary>
|
||||||
|
public string DetailedAnalysis { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// ── Per-Response Snapshots ──
|
||||||
|
public List<ResponseSnapshot> ResponseSnapshots { get; set; } = new();
|
||||||
|
|
||||||
|
// ── Cross-Response Patterns ──
|
||||||
|
public List<PatternInsight> PatternInsights { get; set; } = new();
|
||||||
|
|
||||||
|
// ── Strengths & Concerns ──
|
||||||
|
public List<StrengthFactor> StrengthFactors { get; set; } = new();
|
||||||
|
public List<ConcernFactor> ConcernFactors { get; set; } = new();
|
||||||
|
|
||||||
|
// ── Recommendations ──
|
||||||
|
public List<TrajectoryRecommendation> Recommendations { get; set; } = new();
|
||||||
|
|
||||||
|
// ── Narrative ──
|
||||||
|
/// <summary>
|
||||||
|
/// A story-like professional summary suitable for reports
|
||||||
|
/// </summary>
|
||||||
|
public string TimelineNarrative { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// ── Metadata ──
|
||||||
|
public int TotalResponsesAnalyzed { get; set; }
|
||||||
|
public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool IsIncremental { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ResponseSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The database Response.Id
|
||||||
|
/// </summary>
|
||||||
|
public int ResponseId { get; set; }
|
||||||
|
|
||||||
|
public string ResponseDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string QuestionnaireName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wellness score 0-100 for this specific response
|
||||||
|
/// </summary>
|
||||||
|
public int WellnessScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Low", "Moderate", "High", "Critical"
|
||||||
|
/// </summary>
|
||||||
|
public string RiskLevel { get; set; } = "Low";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Positive", "Negative", "Mixed", "Neutral"
|
||||||
|
/// </summary>
|
||||||
|
public string SentimentLabel { get; set; } = "Neutral";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Key themes detected (e.g., "workload", "management", "isolation")
|
||||||
|
/// </summary>
|
||||||
|
public List<string> KeyThemes { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-sentence summary of this response
|
||||||
|
/// </summary>
|
||||||
|
public string BriefSummary { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PatternInsight
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Description of the pattern (e.g., "Recurring workload concerns across all responses")
|
||||||
|
/// </summary>
|
||||||
|
public string Pattern { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "High", "Medium", "Low"
|
||||||
|
/// </summary>
|
||||||
|
public string Severity { get; set; } = "Medium";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When this pattern was first observed
|
||||||
|
/// </summary>
|
||||||
|
public string FirstSeen { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this pattern is still present in the latest response
|
||||||
|
/// </summary>
|
||||||
|
public bool StillPresent { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StrengthFactor
|
||||||
|
{
|
||||||
|
public string Factor { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConcernFactor
|
||||||
|
{
|
||||||
|
public string Concern { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Immediate", "Monitor", "Low"
|
||||||
|
/// </summary>
|
||||||
|
public string Urgency { get; set; } = "Monitor";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TrajectoryRecommendation
|
||||||
|
{
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Urgent", "High", "Normal"
|
||||||
|
/// </summary>
|
||||||
|
public string Priority { get; set; } = "Normal";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Workplace", "Personal", "Professional Support"
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; set; } = "Workplace";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
585
Services/Implemnetation/UserTrajectoryService.cs
Normal file
585
Services/Implemnetation/UserTrajectoryService.cs
Normal file
|
|
@ -0,0 +1,585 @@
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Services.AIViewModel;
|
||||||
|
using Services.Interaces;
|
||||||
|
using Data;
|
||||||
|
using Model;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Services.Implemnetation
|
||||||
|
{
|
||||||
|
public class UserTrajectoryService : IUserTrajectoryService
|
||||||
|
{
|
||||||
|
private readonly SurveyContext _context;
|
||||||
|
private readonly ILogger<UserTrajectoryService> _logger;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly string _claudeApiKey;
|
||||||
|
private readonly string _claudeModel;
|
||||||
|
|
||||||
|
public UserTrajectoryService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<UserTrajectoryService> logger,
|
||||||
|
SurveyContext context)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_context = context;
|
||||||
|
|
||||||
|
// Claude API configuration
|
||||||
|
_claudeApiKey = configuration["Claude:ApiKey"]
|
||||||
|
?? throw new ArgumentNullException("Claude:ApiKey is missing from configuration");
|
||||||
|
_claudeModel = configuration["Claude:Model"] ?? "claude-sonnet-4-20250514";
|
||||||
|
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("x-api-key", _claudeApiKey);
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||||||
|
|
||||||
|
_logger.LogInformation("UserTrajectoryService initialized with Claude API (Model: {Model})", _claudeModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// PUBLIC METHODS
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
public async Task<UserTrajectoryAnalysis> GetOrAnalyzeTrajectoryAsync(string userEmail)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Count current responses
|
||||||
|
var currentResponses = await GetUserResponses(userEmail);
|
||||||
|
var currentCount = currentResponses.Count;
|
||||||
|
|
||||||
|
if (currentCount == 0)
|
||||||
|
return CreateEmptyResult();
|
||||||
|
|
||||||
|
// 2. Check cache
|
||||||
|
var cache = await _context.UserTrajectoryCaches
|
||||||
|
.FirstOrDefaultAsync(c => c.UserEmail == userEmail);
|
||||||
|
|
||||||
|
if (cache != null && cache.AnalyzedResponseCount == currentCount)
|
||||||
|
{
|
||||||
|
// Cache is fresh — return it
|
||||||
|
_logger.LogInformation("Returning cached trajectory for {Email} ({Count} responses)", userEmail, currentCount);
|
||||||
|
return DeserializeResult(cache.TrajectoryJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Need to analyze
|
||||||
|
UserTrajectoryAnalysis result;
|
||||||
|
|
||||||
|
if (cache == null)
|
||||||
|
{
|
||||||
|
// First-time analysis — send ALL responses
|
||||||
|
_logger.LogInformation("First trajectory analysis for {Email} ({Count} responses)", userEmail, currentCount);
|
||||||
|
var responseText = BuildFullResponseText(currentResponses);
|
||||||
|
result = await CallClaudeFullAnalysis(responseText, currentCount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Incremental — send only NEW responses + previous summary
|
||||||
|
var newResponses = currentResponses
|
||||||
|
.Where(r => r.SubmissionDate > cache.LastResponseDate)
|
||||||
|
.OrderBy(r => r.SubmissionDate)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("Incremental trajectory for {Email} ({New} new of {Total} total)",
|
||||||
|
userEmail, newResponses.Count, currentCount);
|
||||||
|
|
||||||
|
var newResponseText = BuildFullResponseText(newResponses);
|
||||||
|
result = await CallClaudeIncrementalAnalysis(
|
||||||
|
newResponseText, newResponses.Count,
|
||||||
|
cache.PreviousSummary ?? "", currentCount);
|
||||||
|
|
||||||
|
result.IsIncremental = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TotalResponsesAnalyzed = currentCount;
|
||||||
|
result.AnalyzedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// 4. Save to cache
|
||||||
|
var latestDate = currentResponses.Max(r => r.SubmissionDate);
|
||||||
|
var json = SerializeResult(result);
|
||||||
|
var summary = BuildSummaryForCache(result);
|
||||||
|
|
||||||
|
if (cache == null)
|
||||||
|
{
|
||||||
|
_context.UserTrajectoryCaches.Add(new UserTrajectoryCache
|
||||||
|
{
|
||||||
|
UserEmail = userEmail,
|
||||||
|
AnalyzedResponseCount = currentCount,
|
||||||
|
LastResponseDate = latestDate,
|
||||||
|
TrajectoryJson = json,
|
||||||
|
PreviousSummary = summary,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cache.AnalyzedResponseCount = currentCount;
|
||||||
|
cache.LastResponseDate = latestDate;
|
||||||
|
cache.TrajectoryJson = json;
|
||||||
|
cache.PreviousSummary = summary;
|
||||||
|
cache.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error analyzing trajectory for {Email}", userEmail);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserTrajectoryAnalysis> ForceReanalyzeTrajectoryAsync(string userEmail)
|
||||||
|
{
|
||||||
|
var cache = await _context.UserTrajectoryCaches
|
||||||
|
.FirstOrDefaultAsync(c => c.UserEmail == userEmail);
|
||||||
|
|
||||||
|
if (cache != null)
|
||||||
|
{
|
||||||
|
_context.UserTrajectoryCaches.Remove(cache);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GetOrAnalyzeTrajectoryAsync(userEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool HasCache, bool IsStale, int CachedCount, int CurrentCount)> CheckCacheStatusAsync(string userEmail)
|
||||||
|
{
|
||||||
|
var currentCount = await _context.Responses.CountAsync(r => r.UserEmail == userEmail);
|
||||||
|
var cache = await _context.UserTrajectoryCaches.FirstOrDefaultAsync(c => c.UserEmail == userEmail);
|
||||||
|
|
||||||
|
if (cache == null)
|
||||||
|
return (false, true, 0, currentCount);
|
||||||
|
|
||||||
|
var isStale = cache.AnalyzedResponseCount < currentCount;
|
||||||
|
return (true, isStale, cache.AnalyzedResponseCount, currentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// DATA BUILDING — Complete response data
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private async Task<List<Response>> GetUserResponses(string userEmail)
|
||||||
|
{
|
||||||
|
return await _context.Responses
|
||||||
|
.Include(r => r.Questionnaire)
|
||||||
|
.ThenInclude(q => q.Questions)
|
||||||
|
.ThenInclude(q => q.Answers)
|
||||||
|
.Include(r => r.ResponseDetails)
|
||||||
|
.ThenInclude(rd => rd.Question)
|
||||||
|
.ThenInclude(q => q.Answers)
|
||||||
|
.Include(r => r.ResponseDetails)
|
||||||
|
.ThenInclude(rd => rd.ResponseAnswers)
|
||||||
|
.ThenInclude(ra => ra.Answer)
|
||||||
|
.Where(r => r.UserEmail == userEmail)
|
||||||
|
.OrderBy(r => r.SubmissionDate)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a complete text representation of all responses.
|
||||||
|
/// Includes ALL questions with types, available options, and user answers.
|
||||||
|
/// NO personal data (name, email) is included.
|
||||||
|
/// </summary>
|
||||||
|
private string BuildFullResponseText(List<Response> responses)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int responseNum = 0;
|
||||||
|
|
||||||
|
foreach (var response in responses)
|
||||||
|
{
|
||||||
|
responseNum++;
|
||||||
|
sb.AppendLine($"--- Response {responseNum}: \"{response.Questionnaire?.Title ?? "Unknown Survey"}\" (Submitted: {response.SubmissionDate:MMMM dd, yyyy}) ---");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
var questions = response.Questionnaire?.Questions?.OrderBy(q => q.Id).ToList()
|
||||||
|
?? new List<Question>();
|
||||||
|
|
||||||
|
int qNum = 0;
|
||||||
|
foreach (var question in questions)
|
||||||
|
{
|
||||||
|
qNum++;
|
||||||
|
sb.AppendLine($" Q{qNum}: {question.Text} [{question.Type}]");
|
||||||
|
|
||||||
|
// Show available options for non-text questions
|
||||||
|
if (question.Answers != null && question.Answers.Any())
|
||||||
|
{
|
||||||
|
var optionTexts = question.Answers.Select(a => a.Text).ToList();
|
||||||
|
sb.AppendLine($" Options: {string.Join(", ", optionTexts)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user's response detail for this question
|
||||||
|
var detail = response.ResponseDetails?.FirstOrDefault(d => d.QuestionId == question.Id);
|
||||||
|
|
||||||
|
if (detail == null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" → (No response recorded)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Text response
|
||||||
|
if (!string.IsNullOrWhiteSpace(detail.TextResponse))
|
||||||
|
{
|
||||||
|
sb.AppendLine($" → Text Answer: \"{detail.TextResponse}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected answers (checkbox, radio, multiple choice)
|
||||||
|
if (detail.ResponseAnswers != null && detail.ResponseAnswers.Any())
|
||||||
|
{
|
||||||
|
var selectedTexts = detail.ResponseAnswers
|
||||||
|
.Where(ra => ra.Answer != null)
|
||||||
|
.Select(ra => ra.Answer!.Text)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (selectedTexts.Any())
|
||||||
|
{
|
||||||
|
sb.AppendLine($" → Selected: {string.Join(", ", selectedTexts)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other/custom text
|
||||||
|
if (!string.IsNullOrWhiteSpace(detail.OtherText))
|
||||||
|
{
|
||||||
|
sb.AppendLine($" → Custom Response: \"{detail.OtherText}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
if (detail.Status == ResponseStatus.Skipped)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" → (Skipped{(string.IsNullOrEmpty(detail.SkipReason) ? "" : $": {detail.SkipReason}")})");
|
||||||
|
}
|
||||||
|
else if (detail.Status == ResponseStatus.Shown)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" → (Shown but not answered)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// CLAUDE API CALLS
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private async Task<UserTrajectoryAnalysis> CallClaudeFullAnalysis(string responseText, int responseCount)
|
||||||
|
{
|
||||||
|
var isMultiple = responseCount > 1;
|
||||||
|
var trajectoryInstruction = isMultiple
|
||||||
|
? "Analyze the TRAJECTORY of this employee's mental health over time. Compare responses chronologically. Determine if their wellbeing is improving, stable, declining, or fluctuating."
|
||||||
|
: "This is a SINGLE initial assessment. Analyze the employee's current mental health state as a baseline evaluation. Set trajectoryDirection to 'Initial'.";
|
||||||
|
|
||||||
|
var systemPrompt = "You are a senior workplace mental health consultant. You analyze anonymized employee survey responses to assess mental health trajectories over time. You NEVER receive personal data — only survey questions, answer options, and the employee's responses. Always respond with a SINGLE valid JSON object. No markdown, no code fences, no explanations outside the JSON.";
|
||||||
|
|
||||||
|
var userPrompt = $@"{trajectoryInstruction}
|
||||||
|
|
||||||
|
Employee Survey Data ({responseCount} response{(responseCount > 1 ? "s" : "")}):
|
||||||
|
|
||||||
|
{responseText}
|
||||||
|
|
||||||
|
Respond with this exact JSON structure:
|
||||||
|
{{
|
||||||
|
""trajectoryDirection"": ""Improving|Stable|Declining|Fluctuating|Initial"",
|
||||||
|
""trajectoryScore"": 0,
|
||||||
|
""scoreChange"": 0,
|
||||||
|
""overallRiskLevel"": ""Low|Moderate|High|Critical"",
|
||||||
|
""executiveSummary"": ""2-3 sentence overview"",
|
||||||
|
""detailedAnalysis"": ""Detailed paragraph with specific observations"",
|
||||||
|
""responseSnapshots"": [
|
||||||
|
{{
|
||||||
|
""responseDate"": ""MMMM dd, yyyy"",
|
||||||
|
""questionnaireName"": ""name"",
|
||||||
|
""wellnessScore"": 0,
|
||||||
|
""riskLevel"": ""Low|Moderate|High|Critical"",
|
||||||
|
""sentimentLabel"": ""Positive|Negative|Mixed|Neutral"",
|
||||||
|
""keyThemes"": [""theme1"", ""theme2""],
|
||||||
|
""briefSummary"": ""One sentence""
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
""patternInsights"": [
|
||||||
|
{{
|
||||||
|
""pattern"": ""Description of cross-response pattern"",
|
||||||
|
""severity"": ""High|Medium|Low"",
|
||||||
|
""firstSeen"": ""date"",
|
||||||
|
""stillPresent"": true
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
""strengthFactors"": [
|
||||||
|
{{ ""factor"": ""Positive observation"" }}
|
||||||
|
],
|
||||||
|
""concernFactors"": [
|
||||||
|
{{ ""concern"": ""Concern description"", ""urgency"": ""Immediate|Monitor|Low"" }}
|
||||||
|
],
|
||||||
|
""recommendations"": [
|
||||||
|
{{ ""action"": ""Specific action"", ""priority"": ""Urgent|High|Normal"", ""category"": ""Workplace|Personal|Professional Support"" }}
|
||||||
|
],
|
||||||
|
""timelineNarrative"": ""A professional narrative describing the employee's mental health journey suitable for case reports""
|
||||||
|
}}
|
||||||
|
|
||||||
|
IMPORTANT: trajectoryScore and wellnessScore must be integers 0-100 where 100 is excellent mental health. scoreChange is the difference between first and last response scores (positive = improvement). Provide at least 2 patternInsights, 2 strengthFactors, 2 concernFactors, and 3 recommendations.";
|
||||||
|
|
||||||
|
return await CallClaudeApi(systemPrompt, userPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UserTrajectoryAnalysis> CallClaudeIncrementalAnalysis(
|
||||||
|
string newResponseText, int newCount, string previousSummary, int totalCount)
|
||||||
|
{
|
||||||
|
var systemPrompt = "You are a senior workplace mental health consultant. You are updating an existing employee trajectory analysis with new survey data. You NEVER receive personal data — only survey questions, answer options, and responses. Always respond with a SINGLE valid JSON object. No markdown, no code fences.";
|
||||||
|
|
||||||
|
var userPrompt = $@"PREVIOUS ANALYSIS SUMMARY (based on {totalCount - newCount} earlier responses):
|
||||||
|
{previousSummary}
|
||||||
|
|
||||||
|
NEW SURVEY DATA ({newCount} new response{(newCount > 1 ? "s" : "")} — total is now {totalCount}):
|
||||||
|
|
||||||
|
{newResponseText}
|
||||||
|
|
||||||
|
Update the trajectory analysis incorporating this new data with the existing history. The trajectoryScore and scoreChange should reflect the FULL trajectory (all {totalCount} responses), not just the new ones.
|
||||||
|
|
||||||
|
Respond with this exact JSON structure:
|
||||||
|
{{
|
||||||
|
""trajectoryDirection"": ""Improving|Stable|Declining|Fluctuating"",
|
||||||
|
""trajectoryScore"": 0,
|
||||||
|
""scoreChange"": 0,
|
||||||
|
""overallRiskLevel"": ""Low|Moderate|High|Critical"",
|
||||||
|
""executiveSummary"": ""2-3 sentence overview of full trajectory"",
|
||||||
|
""detailedAnalysis"": ""Detailed paragraph incorporating both old and new data"",
|
||||||
|
""responseSnapshots"": [
|
||||||
|
{{
|
||||||
|
""responseDate"": ""date"",
|
||||||
|
""questionnaireName"": ""name"",
|
||||||
|
""wellnessScore"": 0,
|
||||||
|
""riskLevel"": ""Low|Moderate|High|Critical"",
|
||||||
|
""sentimentLabel"": ""Positive|Negative|Mixed|Neutral"",
|
||||||
|
""keyThemes"": [""theme1""],
|
||||||
|
""briefSummary"": ""One sentence""
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
""patternInsights"": [
|
||||||
|
{{ ""pattern"": ""description"", ""severity"": ""High|Medium|Low"", ""firstSeen"": ""date"", ""stillPresent"": true }}
|
||||||
|
],
|
||||||
|
""strengthFactors"": [{{ ""factor"": ""observation"" }}],
|
||||||
|
""concernFactors"": [{{ ""concern"": ""description"", ""urgency"": ""Immediate|Monitor|Low"" }}],
|
||||||
|
""recommendations"": [{{ ""action"": ""action"", ""priority"": ""Urgent|High|Normal"", ""category"": ""Workplace|Personal|Professional Support"" }}],
|
||||||
|
""timelineNarrative"": ""Updated professional narrative for full trajectory""
|
||||||
|
}}
|
||||||
|
|
||||||
|
IMPORTANT: responseSnapshots should include entries for ALL {totalCount} responses (use the previous summary to reconstruct earlier snapshots). Scores are 0-100 integers.";
|
||||||
|
|
||||||
|
return await CallClaudeApi(systemPrompt, userPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// CLAUDE HTTP API CALL
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private async Task<UserTrajectoryAnalysis> CallClaudeApi(string systemPrompt, string userPrompt)
|
||||||
|
{
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
model = _claudeModel,
|
||||||
|
max_tokens = 4096,
|
||||||
|
system = systemPrompt,
|
||||||
|
messages = new[]
|
||||||
|
{
|
||||||
|
new { role = "user", content = userPrompt }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var jsonRequest = JsonSerializer.Serialize(requestBody);
|
||||||
|
var httpContent = new StringContent(jsonRequest, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var httpResponse = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", httpContent);
|
||||||
|
|
||||||
|
if (!httpResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await httpResponse.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogError("Claude API error {StatusCode}: {Error}", httpResponse.StatusCode, errorBody);
|
||||||
|
throw new Exception($"Claude API returned {httpResponse.StatusCode}: {errorBody}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseJson = await httpResponse.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogInformation("Claude API response length: {Length} chars", responseJson.Length);
|
||||||
|
|
||||||
|
// Extract the text content from Claude's response
|
||||||
|
var claudeResponse = JsonSerializer.Deserialize<JsonElement>(responseJson);
|
||||||
|
var contentArray = claudeResponse.GetProperty("content");
|
||||||
|
var textContent = "";
|
||||||
|
|
||||||
|
foreach (var block in contentArray.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (block.GetProperty("type").GetString() == "text")
|
||||||
|
{
|
||||||
|
textContent = block.GetProperty("text").GetString() ?? "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(textContent))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Claude returned empty text content");
|
||||||
|
return CreateFallbackResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON from Claude's text response
|
||||||
|
var result = DeserializeLenient<UserTrajectoryAnalysis>(textContent, out var error);
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to parse trajectory JSON from Claude: {Error}. Raw (first 1000): {Raw}",
|
||||||
|
error, textContent.Length > 1000 ? textContent[..1000] : textContent);
|
||||||
|
return CreateFallbackResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// CACHE HELPERS
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private string BuildSummaryForCache(UserTrajectoryAnalysis result)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Trajectory: {result.TrajectoryDirection} | Score: {result.TrajectoryScore}/100 | Risk: {result.OverallRiskLevel}");
|
||||||
|
sb.AppendLine($"Summary: {result.ExecutiveSummary}");
|
||||||
|
|
||||||
|
if (result.ResponseSnapshots.Any())
|
||||||
|
{
|
||||||
|
sb.AppendLine("Response History:");
|
||||||
|
foreach (var snap in result.ResponseSnapshots)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" - {snap.ResponseDate} ({snap.QuestionnaireName}): Wellness {snap.WellnessScore}/100, {snap.RiskLevel} risk, {snap.SentimentLabel} sentiment. {snap.BriefSummary}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.PatternInsights.Any())
|
||||||
|
{
|
||||||
|
sb.AppendLine("Key Patterns: " + string.Join("; ", result.PatternInsights.Select(p => $"{p.Pattern} ({p.Severity})")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ConcernFactors.Any())
|
||||||
|
{
|
||||||
|
sb.AppendLine("Concerns: " + string.Join("; ", result.ConcernFactors.Select(c => $"{c.Concern} ({c.Urgency})")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.StrengthFactors.Any())
|
||||||
|
{
|
||||||
|
sb.AppendLine("Strengths: " + string.Join("; ", result.StrengthFactors.Select(s => s.Factor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserTrajectoryAnalysis CreateEmptyResult()
|
||||||
|
{
|
||||||
|
return new UserTrajectoryAnalysis
|
||||||
|
{
|
||||||
|
TrajectoryDirection = "Initial",
|
||||||
|
TrajectoryScore = 0,
|
||||||
|
OverallRiskLevel = "Low",
|
||||||
|
ExecutiveSummary = "No survey responses found for this user.",
|
||||||
|
AnalyzedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserTrajectoryAnalysis CreateFallbackResult()
|
||||||
|
{
|
||||||
|
return new UserTrajectoryAnalysis
|
||||||
|
{
|
||||||
|
TrajectoryDirection = "Initial",
|
||||||
|
TrajectoryScore = 50,
|
||||||
|
OverallRiskLevel = "Moderate",
|
||||||
|
ExecutiveSummary = "Analysis could not be fully parsed. Please try re-analyzing.",
|
||||||
|
DetailedAnalysis = "The AI response could not be parsed into the expected format. Please use the 'Re-analyze' option to try again.",
|
||||||
|
AnalyzedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// JSON SERIALIZATION
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
private string SerializeResult(UserTrajectoryAnalysis result)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(result, _jsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserTrajectoryAnalysis DeserializeResult(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<UserTrajectoryAnalysis>(json, _jsonOptions)
|
||||||
|
?? new UserTrajectoryAnalysis();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to deserialize cached trajectory JSON");
|
||||||
|
return new UserTrajectoryAnalysis
|
||||||
|
{
|
||||||
|
ExecutiveSummary = "Cached data could not be loaded. Please re-analyze."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// JSON PARSE HELPERS
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private static readonly Regex JsonObjectRegex =
|
||||||
|
new(@"(\{(?:[^{}]|(?<o>\{)|(?<-o>\}))*(?(o)(?!))\})",
|
||||||
|
RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static T? DeserializeLenient<T>(string content, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
var json = content?.Trim() ?? "";
|
||||||
|
|
||||||
|
// Strip markdown fences
|
||||||
|
if (json.StartsWith("```"))
|
||||||
|
{
|
||||||
|
json = Regex.Replace(json, "^```(?:json)?\\s*|\\s*```$", "",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try direct parse first
|
||||||
|
if (json.StartsWith("{") && json.EndsWith("}"))
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<T>(json, _jsonOptions); }
|
||||||
|
catch (Exception ex) { error = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try regex extraction
|
||||||
|
var m = JsonObjectRegex.Match(json);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<T>(m.Value, _jsonOptions); }
|
||||||
|
catch (Exception ex) { error = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
error ??= "No valid JSON object found in response.";
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,142 +3,152 @@ using Services.AIViewModel;
|
||||||
|
|
||||||
namespace Services.Interaces
|
namespace Services.Interaces
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unified AI analysis service powered by Claude API (Anthropic).
|
||||||
|
/// Provides sentiment analysis, risk assessment, key phrase extraction,
|
||||||
|
/// PII anonymization, workplace insights, and executive reporting.
|
||||||
|
/// </summary>
|
||||||
public interface IAiAnalysisService
|
public interface IAiAnalysisService
|
||||||
{
|
{
|
||||||
#region Azure Language Service Methods
|
#region Core Analysis Methods
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Analyzes sentiment of response text using Azure Language Service
|
/// Analyzes sentiment of response text (Positive, Negative, Neutral)
|
||||||
|
/// with confidence scores using Claude AI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<SentimentAnalysisResult> AnalyzeSentimentAsync(string text);
|
Task<SentimentAnalysisResult> AnalyzeSentimentAsync(string text);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts key phrases and workplace factors from response text
|
/// Extracts key phrases, workplace factors, and emotional indicators
|
||||||
|
/// from response text using Claude AI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<KeyPhrasesResult> ExtractKeyPhrasesAsync(string text);
|
Task<KeyPhrasesResult> ExtractKeyPhrasesAsync(string text);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes PII (Personally Identifiable Information) from response text
|
/// Removes PII (names, emails, phone numbers, addresses) from text
|
||||||
|
/// using Claude AI entity recognition.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> AnonymizeTextAsync(string text);
|
Task<string> AnonymizeTextAsync(string text);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Detects entities in text (workplace factors, departments, roles, etc.)
|
/// Detects named entities in text (people, organizations, locations, roles).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<string>> DetectEntitiesAsync(string text);
|
Task<List<string>> DetectEntitiesAsync(string text);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Azure OpenAI Methods
|
#region Risk Assessment Methods
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Assesses mental health risk level using GPT-3.5 Turbo
|
/// Assesses mental health risk level (Low → Critical) with indicators,
|
||||||
|
/// protective factors, and recommended actions using Claude AI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<MentalHealthRiskAssessment> AssessMentalHealthRiskAsync(string anonymizedText, string questionContext);
|
Task<MentalHealthRiskAssessment> AssessMentalHealthRiskAsync(string anonymizedText, string questionContext);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates workplace insights and intervention recommendations
|
/// Generates workplace insights and intervention recommendations
|
||||||
|
/// categorized by priority and affected areas.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<WorkplaceInsight>> GenerateWorkplaceInsightsAsync(string anonymizedText, string questionContext);
|
Task<List<WorkplaceInsight>> GenerateWorkplaceInsightsAsync(string anonymizedText, string questionContext);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates executive summary for questionnaire analysis
|
/// Creates a professional executive summary from aggregated analysis results
|
||||||
|
/// suitable for C-level reporting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> GenerateExecutiveSummaryAsync(List<ResponseAnalysisResult> analysisResults);
|
Task<string> GenerateExecutiveSummaryAsync(List<ResponseAnalysisResult> analysisResults);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Categorizes responses into mental health themes
|
/// Categorizes response into workplace mental health themes
|
||||||
|
/// (Work-Life Balance, Burnout, Leadership, etc.).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<string>> CategorizeResponseAsync(string anonymizedText);
|
Task<List<string>> CategorizeResponseAsync(string anonymizedText);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Combined Analysis Methods
|
#region Composite Analysis Methods
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs complete AI analysis on a single response (both Azure services)
|
/// Performs full analysis pipeline on a single response:
|
||||||
|
/// Anonymize → Sentiment → Key Phrases → Risk → Insights.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<ResponseAnalysisResult> AnalyzeCompleteResponseAsync(AnalysisRequest request);
|
Task<ResponseAnalysisResult> AnalyzeCompleteResponseAsync(AnalysisRequest request);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Analyzes multiple responses for a specific question
|
/// Analyzes multiple responses for a specific question.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<ResponseAnalysisResult>> AnalyzeQuestionResponsesAsync(int questionId, List<AnalysisRequest> requests);
|
Task<List<ResponseAnalysisResult>> AnalyzeQuestionResponsesAsync(int questionId, List<AnalysisRequest> requests);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates comprehensive analysis overview for entire questionnaire
|
/// Generates comprehensive analysis overview for an entire questionnaire
|
||||||
|
/// including sentiment distribution, risk breakdown, and executive summary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<QuestionnaireAnalysisOverview> GenerateQuestionnaireOverviewAsync(int questionnaireId);
|
Task<QuestionnaireAnalysisOverview> GenerateQuestionnaireOverviewAsync(int questionnaireId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Batch processes multiple responses efficiently
|
/// Batch processes multiple responses with rate-limit-aware concurrency.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<ResponseAnalysisResult>> BatchAnalyzeResponsesAsync(List<AnalysisRequest> requests);
|
Task<List<ResponseAnalysisResult>> BatchAnalyzeResponsesAsync(List<AnalysisRequest> requests);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Mental Health Specific Methods
|
#region Mental Health Intelligence
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Identifies responses requiring immediate attention (high risk)
|
/// Identifies responses flagged as High or Critical risk
|
||||||
|
/// requiring immediate organizational attention.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<ResponseAnalysisResult>> IdentifyHighRiskResponsesAsync(int questionnaireId);
|
Task<List<ResponseAnalysisResult>> IdentifyHighRiskResponsesAsync(int questionnaireId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates mental health trends across time periods
|
/// Analyzes mental health trends across a date range.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<WorkplaceInsight>> AnalyzeMentalHealthTrendsAsync(int questionnaireId, DateTime fromDate, DateTime toDate);
|
Task<List<WorkplaceInsight>> AnalyzeMentalHealthTrendsAsync(int questionnaireId, DateTime fromDate, DateTime toDate);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares mental health metrics between departments/teams
|
/// Compares mental health metrics across team identifiers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Dictionary<string, QuestionnaireAnalysisOverview>> CompareTeamMentalHealthAsync(int questionnaireId, List<string> teamIdentifiers);
|
Task<Dictionary<string, QuestionnaireAnalysisOverview>> CompareTeamMentalHealthAsync(int questionnaireId, List<string> teamIdentifiers);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates intervention recommendations based on overall analysis
|
/// Generates prioritized intervention recommendations based on analysis.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<WorkplaceInsight>> GenerateInterventionRecommendationsAsync(int questionnaireId);
|
Task<List<WorkplaceInsight>> GenerateInterventionRecommendationsAsync(int questionnaireId);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Reporting Methods
|
#region Reporting
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates detailed analysis report for specific questionnaire
|
/// Creates a detailed markdown analysis report for management review.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> GenerateDetailedAnalysisReportAsync(int questionnaireId);
|
Task<string> GenerateDetailedAnalysisReportAsync(int questionnaireId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates anonymized data export for further analysis
|
/// Exports fully anonymized analysis data for external processing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<ResponseAnalysisResult>> ExportAnonymizedAnalysisAsync(int questionnaireId);
|
Task<List<ResponseAnalysisResult>> ExportAnonymizedAnalysisAsync(int questionnaireId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates management dashboard summary
|
/// Generates management dashboard data with KPIs and summaries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<QuestionnaireAnalysisOverview> GenerateManagementDashboardAsync(int questionnaireId);
|
Task<QuestionnaireAnalysisOverview> GenerateManagementDashboardAsync(int questionnaireId);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Utility Methods
|
#region Service Health
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests connection to Azure Language Service
|
/// Tests the Claude API connection with a minimal request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> TestAzureLanguageServiceConnectionAsync();
|
Task<bool> TestClaudeConnectionAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests connection to Azure OpenAI Service
|
/// Validates an analysis request before processing.
|
||||||
/// </summary>
|
|
||||||
Task<bool> TestAzureOpenAIConnectionAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates analysis request before processing
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ValidateAnalysisRequestAsync(AnalysisRequest request);
|
Task<bool> ValidateAnalysisRequestAsync(AnalysisRequest request);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets analysis service health status
|
/// Returns service health status. Key: "Claude", Value: online/offline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Dictionary<string, bool>> GetServiceHealthStatusAsync();
|
Task<Dictionary<string, bool>> GetServiceHealthStatusAsync();
|
||||||
|
|
||||||
|
|
|
||||||
30
Services/Interaces/IUserTrajectoryService.cs
Normal file
30
Services/Interaces/IUserTrajectoryService.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
|
||||||
|
|
||||||
|
using Services.AIViewModel;
|
||||||
|
|
||||||
|
namespace Services.Interaces
|
||||||
|
{
|
||||||
|
public interface IUserTrajectoryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes the wellness trajectory for a user.
|
||||||
|
/// Uses cached results when available; calls Claude API only when
|
||||||
|
/// new responses exist since the last analysis.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userEmail">The user's email to look up responses</param>
|
||||||
|
/// <returns>Complete trajectory analysis</returns>
|
||||||
|
Task<UserTrajectoryAnalysis> GetOrAnalyzeTrajectoryAsync(string userEmail);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forces a fresh analysis regardless of cache state.
|
||||||
|
/// Useful when admin wants to re-analyze.
|
||||||
|
/// </summary>
|
||||||
|
Task<UserTrajectoryAnalysis> ForceReanalyzeTrajectoryAsync(string userEmail);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a cached analysis exists and whether it's stale
|
||||||
|
/// (i.e., new responses exist since last analysis).
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool HasCache, bool IsStale, int CachedCount, int CurrentCount)> CheckCacheStatusAsync(string userEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Web/Areas/Admin/Controllers/AccessDeniedController.cs
Normal file
15
Web/Areas/Admin/Controllers/AccessDeniedController.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Web.Areas.Admin.Controllers
|
||||||
|
{
|
||||||
|
[Area("Admin")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public class AccessDeniedController : Controller
|
||||||
|
{
|
||||||
|
public IActionResult Index()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ using Web.ViewModel.AddressVM;
|
||||||
|
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
[Area("Admin")]
|
||||||
public class AddressController : Controller
|
public class AddressController : Controller
|
||||||
{
|
{
|
||||||
private readonly IAddressRepository _addresContext;
|
private readonly IAddressRepository _addresContext;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Model;
|
using Model;
|
||||||
using Data;
|
|
||||||
using Services.Interaces;
|
using Services.Interaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Web.Authorization;
|
||||||
using Web.ViewModel.DashboardVM;
|
using Web.ViewModel.DashboardVM;
|
||||||
|
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
[Authorize(Roles = "Admin,Demo")]
|
[Area("Admin")]
|
||||||
|
[HasPermission(Permissions.Dashboard.View)]
|
||||||
public class AdminController : Controller
|
public class AdminController : Controller
|
||||||
{
|
{
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
|
@ -533,16 +535,20 @@ namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
var thirtyDaysAgo = DateTime.Now.AddDays(-30);
|
var thirtyDaysAgo = DateTime.Now.AddDays(-30);
|
||||||
|
|
||||||
var trendData = await _context.Responses
|
var responses = await _context.Responses
|
||||||
.Where(r => r.SubmissionDate >= thirtyDaysAgo)
|
.Where(r => r.SubmissionDate >= thirtyDaysAgo)
|
||||||
.GroupBy(r => r.SubmissionDate.Date)
|
.Select(r => new { r.SubmissionDate })
|
||||||
|
.ToListAsync(); // Pull into memory first
|
||||||
|
|
||||||
|
var trendData = responses
|
||||||
|
.GroupBy(r => r.SubmissionDate.Date) // Now safe — in-memory grouping
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
Date = g.Key.ToString("yyyy-MM-dd"),
|
Date = g.Key.ToString("yyyy-MM-dd"),
|
||||||
Responses = g.Count()
|
Responses = g.Count()
|
||||||
})
|
})
|
||||||
.OrderBy(d => d.Date)
|
.OrderBy(d => d.Date)
|
||||||
.ToListAsync();
|
.ToList();
|
||||||
|
|
||||||
return Json(trendData);
|
return Json(trendData);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ using Web.ViewModel.BannerVM;
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
public class BannerController : Controller
|
public class BannerController : Controller
|
||||||
{
|
{
|
||||||
private readonly IBannerRepository _banner;
|
private readonly IBannerRepository _banner;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
public class FooterController : Controller
|
public class FooterController : Controller
|
||||||
{
|
{
|
||||||
private readonly IFooterRepository _footer;
|
private readonly IFooterRepository _footer;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ using System.Text.RegularExpressions;
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
public class NewslettersController : Controller
|
public class NewslettersController : Controller
|
||||||
{
|
{
|
||||||
private readonly INewsLetterRepository _repository;
|
private readonly INewsLetterRepository _repository;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ using Web.ViewModel.PageVM;
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
public class PageController : Controller
|
public class PageController : Controller
|
||||||
{
|
{
|
||||||
private readonly IPageRepository _pageRepository;
|
private readonly IPageRepository _pageRepository;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
|
using Web.Authorization;
|
||||||
using Web.ViewModel.QuestionnaireVM;
|
using Web.ViewModel.QuestionnaireVM;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,7 +23,8 @@ namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
|
[HasPermission(Permissions.Questionnaires.View)]
|
||||||
public class QuestionnaireController : Controller
|
public class QuestionnaireController : Controller
|
||||||
{
|
{
|
||||||
private readonly IQuestionnaireRepository _questionnaire;
|
private readonly IQuestionnaireRepository _questionnaire;
|
||||||
|
|
@ -97,7 +99,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Roles = "Admin")]
|
[HasPermission(Permissions.Questionnaires.Create)]
|
||||||
public IActionResult Create()
|
public IActionResult Create()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,7 +123,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
return View(questionnaire);
|
return View(questionnaire);
|
||||||
}
|
}
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "Admin")]
|
[HasPermission(Permissions.Questionnaires.Create)]
|
||||||
public async Task<IActionResult> Create(QuestionnaireViewModel viewmodel)
|
public async Task<IActionResult> Create(QuestionnaireViewModel viewmodel)
|
||||||
{
|
{
|
||||||
if (ModelState.IsValid)
|
if (ModelState.IsValid)
|
||||||
|
|
@ -133,10 +135,10 @@ namespace Web.Areas.Admin.Controllers
|
||||||
Description = viewmodel.Description,
|
Description = viewmodel.Description,
|
||||||
};
|
};
|
||||||
|
|
||||||
var questions = viewmodel.Questions;
|
for (int qIndex = 0; qIndex < viewmodel.Questions.Count; qIndex++)
|
||||||
|
|
||||||
foreach (var questionViewModel in viewmodel.Questions)
|
|
||||||
{
|
{
|
||||||
|
var questionViewModel = viewmodel.Questions[qIndex];
|
||||||
|
|
||||||
var question = new Question
|
var question = new Question
|
||||||
{
|
{
|
||||||
QuestionnaireId = questionViewModel.QuestionnaireId,
|
QuestionnaireId = questionViewModel.QuestionnaireId,
|
||||||
|
|
@ -145,20 +147,62 @@ namespace Web.Areas.Admin.Controllers
|
||||||
Answers = new List<Answer>()
|
Answers = new List<Answer>()
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var answerViewModel in questionViewModel.Answers)
|
// Handle Image type questions — save uploaded files
|
||||||
|
if (questionViewModel.Type == QuestionType.Image)
|
||||||
{
|
{
|
||||||
// Skip empty answers
|
var imageFiles = HttpContext.Request.Form.Files
|
||||||
if (string.IsNullOrWhiteSpace(answerViewModel.Text))
|
.Where(f => f.Name.StartsWith($"ImageFiles_{qIndex}_"))
|
||||||
continue;
|
.OrderBy(f => f.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var answer = new Answer
|
foreach (var imageFile in imageFiles)
|
||||||
{
|
{
|
||||||
Text = answerViewModel.Text,
|
if (imageFile != null && imageFile.Length > 0)
|
||||||
QuestionId = answerViewModel.QuestionId,
|
{
|
||||||
IsOtherOption = answerViewModel.IsOtherOption // NEW: Handle IsOtherOption property
|
var fileExtension = Path.GetExtension(imageFile.FileName);
|
||||||
};
|
var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}";
|
||||||
|
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "questionimages");
|
||||||
|
|
||||||
question.Answers.Add(answer);
|
if (!Directory.Exists(uploadsFolder))
|
||||||
|
Directory.CreateDirectory(uploadsFolder);
|
||||||
|
|
||||||
|
var filePath = Path.Combine(uploadsFolder, uniqueFileName);
|
||||||
|
|
||||||
|
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
|
{
|
||||||
|
await imageFile.CopyToAsync(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
var answer = new Answer
|
||||||
|
{
|
||||||
|
Text = $"/uploads/questionimages/{uniqueFileName}",
|
||||||
|
QuestionId = question.Id,
|
||||||
|
IsOtherOption = false,
|
||||||
|
ConditionJson = null
|
||||||
|
};
|
||||||
|
|
||||||
|
question.Answers.Add(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Handle all other question types
|
||||||
|
if (questionViewModel.Answers != null)
|
||||||
|
{
|
||||||
|
foreach (var answerModel in questionViewModel.Answers)
|
||||||
|
{
|
||||||
|
var answer = new Answer
|
||||||
|
{
|
||||||
|
Text = answerModel.Text,
|
||||||
|
QuestionId = answerModel.QuestionId,
|
||||||
|
IsOtherOption = answerModel.IsOtherOption,
|
||||||
|
ConditionJson = answerModel.ConditionJson // Save the condition JSON
|
||||||
|
};
|
||||||
|
|
||||||
|
question.Answers.Add(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
questionnaire.Questions.Add(question);
|
questionnaire.Questions.Add(question);
|
||||||
|
|
@ -174,7 +218,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Roles = "Admin")]
|
[HasPermission(Permissions.Questionnaires.Edit)]
|
||||||
public IActionResult Edit(int id)
|
public IActionResult Edit(int id)
|
||||||
{
|
{
|
||||||
var questionTypes = Enum.GetValues(typeof(QuestionType))
|
var questionTypes = Enum.GetValues(typeof(QuestionType))
|
||||||
|
|
@ -217,7 +261,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
|
|
||||||
return View(viewModel);
|
return View(viewModel);
|
||||||
}
|
}
|
||||||
[Authorize(Roles = "Admin")]
|
[HasPermission(Permissions.Questionnaires.Edit)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Edit(EditQuestionnaireViewModel viewModel)
|
public async Task<IActionResult> Edit(EditQuestionnaireViewModel viewModel)
|
||||||
{
|
{
|
||||||
|
|
@ -513,7 +557,8 @@ namespace Web.Areas.Admin.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
|
[HasPermission(Permissions.Questionnaires.Delete)]
|
||||||
public IActionResult Delete(int id)
|
public IActionResult Delete(int id)
|
||||||
{
|
{
|
||||||
var questionTypes = Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>();
|
var questionTypes = Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>();
|
||||||
|
|
@ -551,6 +596,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ActionName("Delete")]
|
[ActionName("Delete")]
|
||||||
|
[HasPermission(Permissions.Questionnaires.Delete)]
|
||||||
public async Task<IActionResult> DeleteConfirm(int id)
|
public async Task<IActionResult> DeleteConfirm(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -579,6 +625,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
||||||
public IActionResult Details(int id)
|
public IActionResult Details(int id)
|
||||||
{
|
{
|
||||||
var questionTypes = Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>();
|
var questionTypes = Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>();
|
||||||
|
|
@ -615,21 +662,25 @@ namespace Web.Areas.Admin.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Permissions.Questionnaires.Send)]
|
||||||
public IActionResult SendQuestionnaire(int id)
|
public IActionResult SendQuestionnaire(int id)
|
||||||
{
|
{
|
||||||
var quesstionnaireFromDb = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(id);
|
var quesstionnaireFromDb = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(id);
|
||||||
var sendquestionviewmodel = new SendQuestionnaireViewModel();
|
var sendquestionviewmodel = new SendQuestionnaireViewModel();
|
||||||
|
|
||||||
sendquestionviewmodel.QuestionnaireId = id;
|
sendquestionviewmodel.QuestionnaireId = id;
|
||||||
ViewBag.questionnaireName = quesstionnaireFromDb.Title;
|
ViewBag.questionnaireName = quesstionnaireFromDb.Title;
|
||||||
|
|
||||||
return View(sendquestionviewmodel);
|
// Users who have submitted ANY questionnaire response
|
||||||
|
ViewBag.Users = _context.Responses
|
||||||
|
.Select(r => new { r.UserName, r.UserEmail })
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return View(sendquestionviewmodel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Permissions.Questionnaires.Send)]
|
||||||
public async Task<IActionResult> SendQuestionnaire(SendQuestionnaireViewModel viewModel)
|
public async Task<IActionResult> SendQuestionnaire(SendQuestionnaireViewModel viewModel)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
|
|
@ -834,7 +885,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
// Add these methods to your existing QuestionnaireController class
|
// Add these methods to your existing QuestionnaireController class
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
public IActionResult SetLogic(int id)
|
public IActionResult SetLogic(int id)
|
||||||
{
|
{
|
||||||
var questionnaire = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(id);
|
var questionnaire = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(id);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
public class RegisterController : Controller
|
public class RegisterController : Controller
|
||||||
{
|
{
|
||||||
public IActionResult Index()
|
public IActionResult Index()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Web.Authorization;
|
||||||
using Web.ViewModel.AccountVM;
|
using Web.ViewModel.AccountVM;
|
||||||
|
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Roles.View)]
|
||||||
|
[Area("Admin")]
|
||||||
public class RolesController : Controller
|
public class RolesController : Controller
|
||||||
{
|
{
|
||||||
private readonly RoleManager<IdentityRole> _roleManager;
|
private readonly RoleManager<IdentityRole> _roleManager;
|
||||||
|
|
@ -15,99 +18,146 @@ namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
_roleManager = roleManager;
|
_roleManager = roleManager;
|
||||||
}
|
}
|
||||||
public IActionResult Index()
|
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var roles = _roleManager.Roles.Select(r => new RoleViewModel
|
var roles = _roleManager.Roles.ToList();
|
||||||
|
var models = new List<RoleViewModel>();
|
||||||
|
|
||||||
|
foreach (var role in roles)
|
||||||
{
|
{
|
||||||
Id = r.Id,
|
var claims = await _roleManager.GetClaimsAsync(role);
|
||||||
Name = r.Name,
|
var permissions = claims
|
||||||
|
.Where(c => c.Type == Permissions.ClaimType)
|
||||||
}).ToList();
|
.Select(c => c.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return View(roles);
|
models.Add(new RoleViewModel
|
||||||
|
{
|
||||||
|
Id = role.Id,
|
||||||
|
Name = role.Name,
|
||||||
|
SelectedPermissions = permissions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass grouped permissions for the UI
|
||||||
|
ViewBag.PermissionGroups = Permissions.GetAllGrouped();
|
||||||
|
|
||||||
|
return View(models);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public IActionResult Create()
|
[HasPermission(Permissions.Roles.Create)]
|
||||||
{
|
|
||||||
return View(new RoleViewModel());
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Create(RoleViewModel model)
|
public async Task<IActionResult> CreateAjax(RoleViewModel model)
|
||||||
{
|
{
|
||||||
if (ModelState.IsValid)
|
if (string.IsNullOrWhiteSpace(model.Name))
|
||||||
{
|
{
|
||||||
var role = new IdentityRole
|
return Json(new { success = false, errors = new List<string> { "Role name is required." } });
|
||||||
{
|
|
||||||
Name = model.Name
|
|
||||||
};
|
|
||||||
// Optionally handle the description if your IdentityRole class supports it
|
|
||||||
var result = await _roleManager.CreateAsync(role);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
TempData["Success"] = "role created successfully";
|
|
||||||
return RedirectToAction("Index");
|
|
||||||
|
|
||||||
}
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError("", error.Description);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return View(model);
|
|
||||||
|
// Check if role already exists
|
||||||
|
var existingRole = await _roleManager.FindByNameAsync(model.Name);
|
||||||
|
if (existingRole != null)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, errors = new List<string> { $"Role '{model.Name}' already exists." } });
|
||||||
|
}
|
||||||
|
|
||||||
|
var role = new IdentityRole { Name = model.Name };
|
||||||
|
var result = await _roleManager.CreateAsync(role);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
// Save permissions as claims
|
||||||
|
if (model.SelectedPermissions != null && model.SelectedPermissions.Any())
|
||||||
|
{
|
||||||
|
foreach (var permission in model.SelectedPermissions)
|
||||||
|
{
|
||||||
|
await _roleManager.AddClaimAsync(role, new Claim(Permissions.ClaimType, permission));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new { success = true, message = $"Role '{model.Name}' created successfully." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors = result.Errors.Select(e => e.Description).ToList();
|
||||||
|
return Json(new { success = false, errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> Edit(string id)
|
[HttpGet]
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Roles.View)]
|
||||||
|
public async Task<IActionResult> GetRolePermissions(string id)
|
||||||
{
|
{
|
||||||
var role = await _roleManager.FindByIdAsync(id);
|
var role = await _roleManager.FindByIdAsync(id);
|
||||||
if (role == null)
|
if (role == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return Json(new { success = false, errors = new List<string> { "Role not found." } });
|
||||||
}
|
}
|
||||||
|
|
||||||
var model = new RoleViewModel
|
var claims = await _roleManager.GetClaimsAsync(role);
|
||||||
{
|
var permissions = claims
|
||||||
Id = role.Id,
|
.Where(c => c.Type == Permissions.ClaimType)
|
||||||
Name = role.Name,
|
.Select(c => c.Value)
|
||||||
|
.ToList();
|
||||||
};
|
|
||||||
|
|
||||||
return View(model);
|
return Json(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
id = role.Id,
|
||||||
|
name = role.Name,
|
||||||
|
permissions
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Edit(RoleViewModel model)
|
[HasPermission(Permissions.Roles.Edit)]
|
||||||
|
public async Task<IActionResult> EditAjax(RoleViewModel model)
|
||||||
{
|
{
|
||||||
if (ModelState.IsValid)
|
if (string.IsNullOrWhiteSpace(model.Name))
|
||||||
{
|
{
|
||||||
var role = await _roleManager.FindByIdAsync(model.Id);
|
return Json(new { success = false, errors = new List<string> { "Role name is required." } });
|
||||||
if (role == null)
|
}
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
role.Name = model.Name;
|
var role = await _roleManager.FindByIdAsync(model.Id);
|
||||||
|
if (role == null)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, errors = new List<string> { "Role not found." } });
|
||||||
|
}
|
||||||
|
|
||||||
var result = await _roleManager.UpdateAsync(role);
|
// Update name
|
||||||
if (result.Succeeded)
|
role.Name = model.Name;
|
||||||
|
var result = await _roleManager.UpdateAsync(role);
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
var errors = result.Errors.Select(e => e.Description).ToList();
|
||||||
|
return Json(new { success = false, errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old permission claims
|
||||||
|
var existingClaims = await _roleManager.GetClaimsAsync(role);
|
||||||
|
foreach (var claim in existingClaims.Where(c => c.Type == Permissions.ClaimType))
|
||||||
|
{
|
||||||
|
await _roleManager.RemoveClaimAsync(role, claim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new permission claims
|
||||||
|
if (model.SelectedPermissions != null && model.SelectedPermissions.Any())
|
||||||
|
{
|
||||||
|
foreach (var permission in model.SelectedPermissions)
|
||||||
{
|
{
|
||||||
TempData["Success"] = "Role updated successfully";
|
await _roleManager.AddClaimAsync(role, new Claim(Permissions.ClaimType, permission));
|
||||||
return RedirectToAction(nameof(Index));
|
|
||||||
}
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError("", error.Description);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return View(model);
|
return Json(new { success = true, message = $"Role '{model.Name}' updated successfully." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Roles.Delete)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> DeleteMultiple(List<string> selectedRoles)
|
public async Task<IActionResult> DeleteMultiple(List<string> selectedRoles)
|
||||||
|
|
@ -123,6 +173,13 @@ namespace Web.Areas.Admin.Controllers
|
||||||
var role = await _roleManager.FindByIdAsync(roleId);
|
var role = await _roleManager.FindByIdAsync(roleId);
|
||||||
if (role != null)
|
if (role != null)
|
||||||
{
|
{
|
||||||
|
// Remove all claims first
|
||||||
|
var claims = await _roleManager.GetClaimsAsync(role);
|
||||||
|
foreach (var claim in claims)
|
||||||
|
{
|
||||||
|
await _roleManager.RemoveClaimAsync(role, claim);
|
||||||
|
}
|
||||||
|
|
||||||
await _roleManager.DeleteAsync(role);
|
await _roleManager.DeleteAsync(role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +187,5 @@ namespace Web.Areas.Admin.Controllers
|
||||||
TempData["Success"] = "Selected roles deleted successfully.";
|
TempData["Success"] = "Selected roles deleted successfully.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ using Web.ViewModel.SocialMediaVM;
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
public class SocialMediaController : Controller
|
public class SocialMediaController : Controller
|
||||||
{
|
{
|
||||||
private readonly ISocialMediaRepository _context;
|
private readonly ISocialMediaRepository _context;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@ using Web.ViewModel.QuestionnaireVM;
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
|
[Area("Admin")]
|
||||||
public class UserResponseController : Controller
|
public class UserResponseController : Controller
|
||||||
{
|
{
|
||||||
private readonly SurveyContext _context;
|
private readonly SurveyContext _context;
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,28 @@ using Model;
|
||||||
using OfficeOpenXml;
|
using OfficeOpenXml;
|
||||||
using Services.Interaces;
|
using Services.Interaces;
|
||||||
using Web.ViewModel.QuestionnaireVM;
|
using Web.ViewModel.QuestionnaireVM;
|
||||||
|
using Services.Interaces;
|
||||||
|
using Services.AIViewModel;
|
||||||
|
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
[Area("Admin")]
|
||||||
public class UserResponseStatusController : Controller
|
public class UserResponseStatusController : Controller
|
||||||
{
|
{
|
||||||
private readonly SurveyContext _context;
|
private readonly SurveyContext _context;
|
||||||
private readonly IUserResponseRepository _userResponse;
|
private readonly IUserResponseRepository _userResponse;
|
||||||
private readonly ILogger<UserResponseStatusController> _logger;
|
private readonly ILogger<UserResponseStatusController> _logger;
|
||||||
|
private readonly IUserTrajectoryService _trajectoryService;
|
||||||
|
|
||||||
public UserResponseStatusController(
|
public UserResponseStatusController(
|
||||||
SurveyContext context,
|
SurveyContext context,
|
||||||
IUserResponseRepository userResponse,
|
IUserResponseRepository userResponse,
|
||||||
ILogger<UserResponseStatusController> logger)
|
ILogger<UserResponseStatusController> logger, IUserTrajectoryService trajectoryService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_userResponse = userResponse;
|
_userResponse = userResponse;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_trajectoryService = trajectoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
|
|
@ -688,5 +693,346 @@ namespace Web.Areas.Admin.Controllers
|
||||||
var count = await _context.Responses.GroupBy(r => r.UserEmail).CountAsync();
|
var count = await _context.Responses.GroupBy(r => r.UserEmail).CountAsync();
|
||||||
return Json(new { count });
|
return Json(new { count });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyze wellness trajectory for a user (AJAX)
|
||||||
|
/// Returns cached result or calls Claude API if new responses exist
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> AnalyzeTrajectory(string userEmail)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(userEmail))
|
||||||
|
return Json(new { success = false, message = "User email is required." });
|
||||||
|
|
||||||
|
// Check if user has any responses
|
||||||
|
var hasResponses = await _context.Responses.AnyAsync(r => r.UserEmail == userEmail);
|
||||||
|
if (!hasResponses)
|
||||||
|
return Json(new { success = false, message = "No responses found for this user." });
|
||||||
|
|
||||||
|
var result = await _trajectoryService.GetOrAnalyzeTrajectoryAsync(userEmail);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
data = result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = "Error analyzing trajectory: " + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Force re-analyze trajectory (ignores cache)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> ReanalyzeTrajectory(string userEmail)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(userEmail))
|
||||||
|
return Json(new { success = false, message = "User email is required." });
|
||||||
|
|
||||||
|
var result = await _trajectoryService.ForceReanalyzeTrajectoryAsync(userEmail);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
data = result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = "Error re-analyzing: " + ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check cache status (AJAX) — useful for showing "new data available" badge
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> CheckTrajectoryStatus(string userEmail)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (hasCache, isStale, cachedCount, currentCount) =
|
||||||
|
await _trajectoryService.CheckCacheStatusAsync(userEmail);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
hasCache,
|
||||||
|
isStale,
|
||||||
|
cachedCount,
|
||||||
|
currentCount,
|
||||||
|
newResponses = currentCount - cachedCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export trajectory analysis as a professional PDF report
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportTrajectoryPdf(string userEmail)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(userEmail))
|
||||||
|
return BadRequest("User email is required.");
|
||||||
|
|
||||||
|
var analysis = await _trajectoryService.GetOrAnalyzeTrajectoryAsync(userEmail);
|
||||||
|
|
||||||
|
var userResponse = await _context.Responses
|
||||||
|
.FirstOrDefaultAsync(r => r.UserEmail == userEmail);
|
||||||
|
var userName = userResponse?.UserName ?? "Unknown";
|
||||||
|
|
||||||
|
// ── Build PDF ──
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
var document = new Document(PageSize.A4, 40, 40, 40, 40);
|
||||||
|
var writer = PdfWriter.GetInstance(document, stream);
|
||||||
|
writer.CloseStream = false;
|
||||||
|
document.Open();
|
||||||
|
|
||||||
|
// Fonts
|
||||||
|
var titleFont = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 20, new BaseColor(30, 41, 59));
|
||||||
|
var h1Font = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 14, new BaseColor(30, 41, 59));
|
||||||
|
var h2Font = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 11, new BaseColor(71, 85, 105));
|
||||||
|
var bodyFont = FontFactory.GetFont(FontFactory.HELVETICA, 10, BaseColor.BLACK);
|
||||||
|
var smallFont = FontFactory.GetFont(FontFactory.HELVETICA, 9, new BaseColor(100, 116, 139));
|
||||||
|
var boldFont = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 10, BaseColor.BLACK);
|
||||||
|
var accentFont = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 10, new BaseColor(79, 70, 229));
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
var primaryBg = new BaseColor(238, 242, 255); // indigo-50
|
||||||
|
var greenBg = new BaseColor(236, 253, 245); // green-50
|
||||||
|
var redBg = new BaseColor(254, 242, 242); // red-50
|
||||||
|
var yellowBg = new BaseColor(254, 252, 232); // yellow-50
|
||||||
|
var grayBg = new BaseColor(248, 250, 252); // slate-50
|
||||||
|
var darkText = new BaseColor(30, 41, 59);
|
||||||
|
var greenText = new BaseColor(22, 163, 74);
|
||||||
|
var redText = new BaseColor(220, 38, 38);
|
||||||
|
var yellowText = new BaseColor(161, 98, 7);
|
||||||
|
|
||||||
|
// ── Logo ──
|
||||||
|
var logoPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images", "logo.png");
|
||||||
|
if (System.IO.File.Exists(logoPath))
|
||||||
|
{
|
||||||
|
var logo = Image.GetInstance(logoPath);
|
||||||
|
logo.ScaleToFit(120f, 60f);
|
||||||
|
logo.Alignment = Image.ALIGN_LEFT;
|
||||||
|
document.Add(logo);
|
||||||
|
document.Add(new Paragraph(" ") { SpacingAfter = 5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Title ──
|
||||||
|
document.Add(new Paragraph("Wellness Trajectory Analysis Report", titleFont) { SpacingAfter = 5 });
|
||||||
|
document.Add(new Paragraph("CONFIDENTIAL — For Professional Use Only",
|
||||||
|
FontFactory.GetFont(FontFactory.HELVETICA, 8, new BaseColor(220, 38, 38)))
|
||||||
|
{ SpacingAfter = 15 });
|
||||||
|
|
||||||
|
// ── User Info Table ──
|
||||||
|
var infoTable = new PdfPTable(2) { WidthPercentage = 100, SpacingAfter = 15 };
|
||||||
|
infoTable.SetWidths(new float[] { 1, 2 });
|
||||||
|
AddInfoRow(infoTable, "Respondent:", userName, boldFont, bodyFont, grayBg);
|
||||||
|
AddInfoRow(infoTable, "Email:", userEmail, boldFont, bodyFont, grayBg);
|
||||||
|
AddInfoRow(infoTable, "Responses Analyzed:", analysis.TotalResponsesAnalyzed.ToString(), boldFont, bodyFont, grayBg);
|
||||||
|
AddInfoRow(infoTable, "Report Generated:", DateTime.UtcNow.ToString("MMMM dd, yyyy HH:mm UTC"), boldFont, bodyFont, grayBg);
|
||||||
|
document.Add(infoTable);
|
||||||
|
|
||||||
|
// ── Trajectory Overview ──
|
||||||
|
AddSectionHeader(document, "TRAJECTORY OVERVIEW", h1Font);
|
||||||
|
|
||||||
|
var overviewTable = new PdfPTable(4) { WidthPercentage = 100, SpacingAfter = 10 };
|
||||||
|
overviewTable.SetWidths(new float[] { 1, 1, 1, 1 });
|
||||||
|
|
||||||
|
AddMetricCell(overviewTable, "Direction", analysis.TrajectoryDirection, accentFont, smallFont, primaryBg);
|
||||||
|
AddMetricCell(overviewTable, "Wellness Score", $"{analysis.TrajectoryScore}/100", accentFont, smallFont, primaryBg);
|
||||||
|
AddMetricCell(overviewTable, "Score Change", $"{(analysis.ScoreChange >= 0 ? "+" : "")}{analysis.ScoreChange}", accentFont, smallFont,
|
||||||
|
analysis.ScoreChange >= 0 ? greenBg : redBg);
|
||||||
|
AddMetricCell(overviewTable, "Risk Level", analysis.OverallRiskLevel,
|
||||||
|
FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 10,
|
||||||
|
analysis.OverallRiskLevel == "Critical" || analysis.OverallRiskLevel == "High" ? redText :
|
||||||
|
analysis.OverallRiskLevel == "Moderate" ? yellowText : greenText),
|
||||||
|
smallFont,
|
||||||
|
analysis.OverallRiskLevel == "Critical" || analysis.OverallRiskLevel == "High" ? redBg :
|
||||||
|
analysis.OverallRiskLevel == "Moderate" ? yellowBg : greenBg);
|
||||||
|
document.Add(overviewTable);
|
||||||
|
|
||||||
|
// Executive Summary
|
||||||
|
document.Add(new Paragraph("Executive Summary", h2Font) { SpacingBefore = 8, SpacingAfter = 5 });
|
||||||
|
document.Add(new Paragraph(analysis.ExecutiveSummary, bodyFont) { SpacingAfter = 15 });
|
||||||
|
|
||||||
|
// ── Response History ──
|
||||||
|
AddSectionHeader(document, "RESPONSE HISTORY", h1Font);
|
||||||
|
|
||||||
|
foreach (var snap in analysis.ResponseSnapshots)
|
||||||
|
{
|
||||||
|
var snapTable = new PdfPTable(4) { WidthPercentage = 100, SpacingAfter = 8 };
|
||||||
|
snapTable.SetWidths(new float[] { 1.5f, 0.8f, 0.8f, 0.8f });
|
||||||
|
|
||||||
|
var nameCell = new PdfPCell(new Phrase($"{snap.ResponseDate} — {snap.QuestionnaireName}", boldFont))
|
||||||
|
{ Colspan = 4, BackgroundColor = grayBg, Padding = 8, BorderColor = new BaseColor(226, 232, 240) };
|
||||||
|
snapTable.AddCell(nameCell);
|
||||||
|
|
||||||
|
AddSnapCell(snapTable, $"Score: {snap.WellnessScore}/100", bodyFont, BaseColor.WHITE);
|
||||||
|
AddSnapCell(snapTable, $"Risk: {snap.RiskLevel}", bodyFont,
|
||||||
|
snap.RiskLevel == "Critical" || snap.RiskLevel == "High" ? redBg :
|
||||||
|
snap.RiskLevel == "Moderate" ? yellowBg : greenBg);
|
||||||
|
AddSnapCell(snapTable, $"Sentiment: {snap.SentimentLabel}", bodyFont, BaseColor.WHITE);
|
||||||
|
AddSnapCell(snapTable, snap.KeyThemes.Any() ? string.Join(", ", snap.KeyThemes) : "—", smallFont, BaseColor.WHITE);
|
||||||
|
|
||||||
|
var summCell = new PdfPCell(new Phrase(snap.BriefSummary, smallFont))
|
||||||
|
{ Colspan = 4, Padding = 6, BorderColor = new BaseColor(226, 232, 240), PaddingBottom = 8 };
|
||||||
|
snapTable.AddCell(summCell);
|
||||||
|
|
||||||
|
document.Add(snapTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pattern Insights ──
|
||||||
|
if (analysis.PatternInsights.Any())
|
||||||
|
{
|
||||||
|
AddSectionHeader(document, "PATTERN INSIGHTS", h1Font);
|
||||||
|
foreach (var p in analysis.PatternInsights)
|
||||||
|
{
|
||||||
|
var status = p.StillPresent ? "Still Present" : "Resolved";
|
||||||
|
var statusColor = p.StillPresent ? redText : greenText;
|
||||||
|
document.Add(new Paragraph($"[{p.Severity.ToUpper()}] {p.Pattern}", boldFont) { SpacingAfter = 2 });
|
||||||
|
document.Add(new Paragraph($"First seen: {p.FirstSeen} — {status}",
|
||||||
|
FontFactory.GetFont(FontFactory.HELVETICA, 9, statusColor))
|
||||||
|
{ SpacingAfter = 8 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strengths ──
|
||||||
|
if (analysis.StrengthFactors.Any())
|
||||||
|
{
|
||||||
|
AddSectionHeader(document, "PROTECTIVE STRENGTHS", h1Font);
|
||||||
|
foreach (var s in analysis.StrengthFactors)
|
||||||
|
{
|
||||||
|
document.Add(new Paragraph($"✓ {s.Factor}",
|
||||||
|
FontFactory.GetFont(FontFactory.HELVETICA, 10, greenText))
|
||||||
|
{ SpacingAfter = 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Concerns ──
|
||||||
|
if (analysis.ConcernFactors.Any())
|
||||||
|
{
|
||||||
|
document.Add(new Paragraph(" ") { SpacingAfter = 5 });
|
||||||
|
AddSectionHeader(document, "AREAS OF CONCERN", h1Font);
|
||||||
|
foreach (var c in analysis.ConcernFactors)
|
||||||
|
{
|
||||||
|
document.Add(new Paragraph($"⚠ [{c.Urgency.ToUpper()}] {c.Concern}",
|
||||||
|
FontFactory.GetFont(FontFactory.HELVETICA, 10,
|
||||||
|
c.Urgency == "Immediate" ? redText : darkText))
|
||||||
|
{ SpacingAfter = 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recommendations ──
|
||||||
|
if (analysis.Recommendations.Any())
|
||||||
|
{
|
||||||
|
document.Add(new Paragraph(" ") { SpacingAfter = 5 });
|
||||||
|
AddSectionHeader(document, "AI RECOMMENDATIONS", h1Font);
|
||||||
|
int recNum = 0;
|
||||||
|
foreach (var r in analysis.Recommendations)
|
||||||
|
{
|
||||||
|
recNum++;
|
||||||
|
document.Add(new Paragraph($"{recNum}. [{r.Priority.ToUpper()}] {r.Action}", boldFont) { SpacingAfter = 2 });
|
||||||
|
document.Add(new Paragraph($" Category: {r.Category}", smallFont) { SpacingAfter = 8 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Narrative ──
|
||||||
|
document.Add(new Paragraph(" ") { SpacingAfter = 5 });
|
||||||
|
AddSectionHeader(document, "PROFESSIONAL NARRATIVE", h1Font);
|
||||||
|
document.Add(new Paragraph(analysis.TimelineNarrative,
|
||||||
|
FontFactory.GetFont(FontFactory.HELVETICA_OBLIQUE, 10, darkText))
|
||||||
|
{ SpacingAfter = 15 });
|
||||||
|
|
||||||
|
// ── Detailed Analysis ──
|
||||||
|
AddSectionHeader(document, "DETAILED CLINICAL ANALYSIS", h1Font);
|
||||||
|
document.Add(new Paragraph(analysis.DetailedAnalysis, bodyFont) { SpacingAfter = 15 });
|
||||||
|
|
||||||
|
// ── Footer ──
|
||||||
|
var footerTable = new PdfPTable(1) { WidthPercentage = 100, SpacingBefore = 20 };
|
||||||
|
var footerCell = new PdfPCell(new Phrase(
|
||||||
|
"This report was generated by AI-powered wellness analysis. It is intended for professional mental health consultants and should be treated as CONFIDENTIAL.",
|
||||||
|
FontFactory.GetFont(FontFactory.HELVETICA, 8, new BaseColor(148, 163, 184))))
|
||||||
|
{
|
||||||
|
BackgroundColor = grayBg,
|
||||||
|
Padding = 10,
|
||||||
|
HorizontalAlignment = Element.ALIGN_CENTER,
|
||||||
|
BorderColor = new BaseColor(226, 232, 240)
|
||||||
|
};
|
||||||
|
footerTable.AddCell(footerCell);
|
||||||
|
document.Add(footerTable);
|
||||||
|
|
||||||
|
document.Close();
|
||||||
|
writer.Close();
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var fileName = $"WellnessTrajectory_{userName.Replace(" ", "_")}_{DateTime.UtcNow:yyyyMMdd}.pdf";
|
||||||
|
return File(stream, "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
TempData["ErrorMessage"] = "Error exporting trajectory report: " + ex.Message;
|
||||||
|
return RedirectToAction(nameof(UserResponsesStatus), new { userEmail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF Helper Methods (add these as private methods in the controller) ──
|
||||||
|
|
||||||
|
private void AddSectionHeader(Document doc, string text, Font font)
|
||||||
|
{
|
||||||
|
var p = new Paragraph(text, font) { SpacingBefore = 10, SpacingAfter = 8 };
|
||||||
|
doc.Add(p);
|
||||||
|
// Add a thin line under the header
|
||||||
|
var line = new PdfPTable(1) { WidthPercentage = 100, SpacingAfter = 8 };
|
||||||
|
var lineCell = new PdfPCell() { FixedHeight = 2, BackgroundColor = new BaseColor(79, 70, 229), Border = 0 };
|
||||||
|
line.AddCell(lineCell);
|
||||||
|
doc.Add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInfoRow(PdfPTable table, string label, string value, Font labelFont, Font valueFont, BaseColor bg)
|
||||||
|
{
|
||||||
|
var lCell = new PdfPCell(new Phrase(label, labelFont))
|
||||||
|
{ BackgroundColor = bg, Padding = 6, BorderColor = new BaseColor(226, 232, 240) };
|
||||||
|
var vCell = new PdfPCell(new Phrase(value, valueFont))
|
||||||
|
{ Padding = 6, BorderColor = new BaseColor(226, 232, 240) };
|
||||||
|
table.AddCell(lCell);
|
||||||
|
table.AddCell(vCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddMetricCell(PdfPTable table, string label, string value, Font valueFont, Font labelFont, BaseColor bg)
|
||||||
|
{
|
||||||
|
var cell = new PdfPCell()
|
||||||
|
{
|
||||||
|
BackgroundColor = bg,
|
||||||
|
Padding = 10,
|
||||||
|
HorizontalAlignment = Element.ALIGN_CENTER,
|
||||||
|
BorderColor = new BaseColor(226, 232, 240)
|
||||||
|
};
|
||||||
|
cell.AddElement(new Paragraph(value, valueFont) { Alignment = Element.ALIGN_CENTER });
|
||||||
|
cell.AddElement(new Paragraph(label, labelFont) { Alignment = Element.ALIGN_CENTER, SpacingBefore = 2 });
|
||||||
|
table.AddCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSnapCell(PdfPTable table, string text, Font font, BaseColor bg)
|
||||||
|
{
|
||||||
|
var cell = new PdfPCell(new Phrase(text, font))
|
||||||
|
{ BackgroundColor = bg, Padding = 6, BorderColor = new BaseColor(226, 232, 240) };
|
||||||
|
table.AddCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,12 +4,14 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Model;
|
using Model;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Web.Authorization;
|
||||||
using Web.ViewModel.AccountVM;
|
using Web.ViewModel.AccountVM;
|
||||||
|
|
||||||
namespace Web.Areas.Admin.Controllers
|
namespace Web.Areas.Admin.Controllers
|
||||||
{
|
{
|
||||||
|
[HasPermission(Permissions.Users.View)]
|
||||||
[Authorize(Roles = "Admin")]
|
[Area("Admin")]
|
||||||
|
|
||||||
public class UsersController : Controller
|
public class UsersController : Controller
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
|
@ -22,27 +24,32 @@ namespace Web.Areas.Admin.Controllers
|
||||||
}
|
}
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var users = _userManager.Users.ToList(); // Consider pagination or asynchronous list retrieval if the user list is very large.
|
var users = _userManager.Users.ToList();
|
||||||
var models = new List<RegisterViewModel>();
|
var models = new List<RegisterViewModel>();
|
||||||
|
|
||||||
foreach (var user in users)
|
foreach (var user in users)
|
||||||
{
|
{
|
||||||
var roles = await _userManager.GetRolesAsync(user); // Await the asynchronous call to get roles.
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
var model = new RegisterViewModel
|
var model = new RegisterViewModel
|
||||||
{
|
{
|
||||||
Id=user.Id,
|
Id = user.Id,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
FirstName = user.FirstName, // Assuming these fields are in ApplicationUser
|
FirstName = user.FirstName,
|
||||||
LastName = user.LastName,
|
LastName = user.LastName,
|
||||||
SelectedRoles = roles.ToList() // Now roles is properly awaited and converted to List<string>.
|
SelectedRoles = roles.ToList()
|
||||||
};
|
};
|
||||||
models.Add(model);
|
models.Add(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass roles for the modals
|
||||||
|
ViewBag.Roles = _roleManager.Roles
|
||||||
|
.Select(r => new SelectListItem { Value = r.Name, Text = r.Name })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return View(models);
|
return View(models);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Users.Create)]
|
||||||
public IActionResult Register()
|
public IActionResult Register()
|
||||||
{
|
{
|
||||||
var model = new RegisterViewModel
|
var model = new RegisterViewModel
|
||||||
|
|
@ -52,6 +59,8 @@ namespace Web.Areas.Admin.Controllers
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Users.Create)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Register(RegisterViewModel model)
|
public async Task<IActionResult> Register(RegisterViewModel model)
|
||||||
|
|
@ -100,6 +109,8 @@ namespace Web.Areas.Admin.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Users.Delete)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> DeleteSelected(List<string> selectedUserIds)
|
public async Task<IActionResult> DeleteSelected(List<string> selectedUserIds)
|
||||||
|
|
@ -120,7 +131,7 @@ namespace Web.Areas.Admin.Controllers
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Users.Edit)]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Edit(string id)
|
public async Task<IActionResult> Edit(string id)
|
||||||
{
|
{
|
||||||
|
|
@ -150,8 +161,11 @@ namespace Web.Areas.Admin.Controllers
|
||||||
return View(viewModel);
|
return View(viewModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Users.Edit)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
|
|
||||||
public async Task<IActionResult> Edit(EditUserViewModel model)
|
public async Task<IActionResult> Edit(EditUserViewModel model)
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByIdAsync(model.Id);
|
var user = await _userManager.FindByIdAsync(model.Id);
|
||||||
|
|
@ -196,8 +210,93 @@ namespace Web.Areas.Admin.Controllers
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Users.Create)]
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> RegisterAjax(RegisterViewModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
var errors = ModelState.Values
|
||||||
|
.SelectMany(v => v.Errors)
|
||||||
|
.Select(e => e.ErrorMessage)
|
||||||
|
.ToList();
|
||||||
|
return Json(new { success = false, errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingUser = await _userManager.FindByEmailAsync(model.Email);
|
||||||
|
if (existingUser != null)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, errors = new List<string> { "A user with this email already exists." } });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = model.Email,
|
||||||
|
Email = model.Email,
|
||||||
|
FirstName = model.FirstName,
|
||||||
|
LastName = model.LastName
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _userManager.CreateAsync(user, model.Password);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
if (model.SelectedRoles != null && model.SelectedRoles.Any())
|
||||||
|
{
|
||||||
|
foreach (var role in model.SelectedRoles)
|
||||||
|
{
|
||||||
|
await _userManager.AddToRoleAsync(user, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new { success = true, message = "User created successfully." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var createErrors = result.Errors.Select(e => e.Description).ToList();
|
||||||
|
return Json(new { success = false, errors = createErrors });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HasPermission(Permissions.Users.Edit)]
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> EditAjax(EditUserViewModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
var errors = ModelState.Values
|
||||||
|
.SelectMany(v => v.Errors)
|
||||||
|
.Select(e => e.ErrorMessage)
|
||||||
|
.ToList();
|
||||||
|
return Json(new { success = false, errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(model.Id);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, errors = new List<string> { $"User with Id = {model.Id} cannot be found." } });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.FirstName = model.FirstName;
|
||||||
|
user.LastName = model.LastName;
|
||||||
|
|
||||||
|
var result = await _userManager.UpdateAsync(user);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
var currentRoles = await _userManager.GetRolesAsync(user);
|
||||||
|
await _userManager.RemoveFromRolesAsync(user, currentRoles);
|
||||||
|
|
||||||
|
if (model.SelectedRoles != null && model.SelectedRoles.Any())
|
||||||
|
{
|
||||||
|
await _userManager.AddToRolesAsync(user, model.SelectedRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new { success = true, message = "User updated successfully." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateErrors = result.Errors.Select(e => e.Description).ToList();
|
||||||
|
return Json(new { success = false, errors = updateErrors });
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
Web/Areas/Admin/Views/AccessDenied/Index.cshtml
Normal file
68
Web/Areas/Admin/Views/AccessDenied/Index.cshtml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Access Denied";
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
@@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
.ad-wrapper { min-height:100vh; background:#0f172a; display:flex; align-items:center; justify-content:center; position:relative; overflow:hidden; font-family:'Space Grotesk',sans-serif; }
|
||||||
|
.ad-bg { position:absolute; inset:0; z-index:0; }
|
||||||
|
.ad-bg .grid { position:absolute; inset:0; background-image:linear-gradient(rgba(248,113,113,0.04) 1px,transparent 1px),linear-gradient(90deg,rgba(248,113,113,0.04) 1px,transparent 1px); background-size:60px 60px; }
|
||||||
|
.ad-bg .mesh { position:absolute; inset:0; background:radial-gradient(circle at 30% 40%,rgba(248,113,113,0.1) 0%,transparent 50%),radial-gradient(circle at 70% 60%,rgba(96,165,250,0.06) 0%,transparent 50%); }
|
||||||
|
|
||||||
|
.ad-card { position:relative; z-index:10; text-align:center; max-width:480px; width:100%; padding:3rem 2.5rem; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:24px; backdrop-filter:blur(12px); }
|
||||||
|
.ad-card .glow { position:absolute; top:0; left:0; right:0; height:3px; border-radius:24px 24px 0 0; background:linear-gradient(90deg,#f87171,#dc2626); }
|
||||||
|
|
||||||
|
.ad-icon { width:80px; height:80px; border-radius:20px; background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.2); display:flex; align-items:center; justify-content:center; margin:0 auto 1.5rem; font-size:2rem; color:#f87171; animation:shake .6s ease-in-out; }
|
||||||
|
@@keyframes shake { 0%,100%{transform:translateX(0)} 15%{transform:translateX(-8px)} 30%{transform:translateX(8px)} 45%{transform:translateX(-6px)} 60%{transform:translateX(6px)} 75%{transform:translateX(-3px)} 90%{transform:translateX(3px)} }
|
||||||
|
|
||||||
|
.ad-code { font-family:'JetBrains Mono',monospace; font-size:3.5rem; font-weight:700; color:#f87171; line-height:1; margin-bottom:0.5rem; }
|
||||||
|
.ad-title { font-size:1.3rem; font-weight:700; color:#f8fafc; margin-bottom:0.75rem; }
|
||||||
|
.ad-text { font-size:0.88rem; color:#94a3b8; line-height:1.7; margin-bottom:2rem; }
|
||||||
|
.ad-text strong { color:#f87171; }
|
||||||
|
|
||||||
|
.ad-info { display:flex; align-items:center; gap:0.75rem; padding:0.85rem 1.25rem; background:rgba(248,113,113,0.08); border:1px solid rgba(248,113,113,0.15); border-radius:12px; margin-bottom:2rem; text-align:left; }
|
||||||
|
.ad-info i { color:#f87171; font-size:1.1rem; flex-shrink:0; }
|
||||||
|
.ad-info span { font-size:0.8rem; color:#cbd5e1; line-height:1.5; }
|
||||||
|
|
||||||
|
.ad-actions { display:flex; gap:0.75rem; justify-content:center; flex-wrap:wrap; }
|
||||||
|
.ad-btn { display:inline-flex; align-items:center; gap:0.5rem; padding:0.65rem 1.5rem; border-radius:10px; font-family:'Space Grotesk',sans-serif; font-size:0.85rem; font-weight:600; border:none; cursor:pointer; transition:all .25s; text-decoration:none; }
|
||||||
|
.ad-btn.pri { background:linear-gradient(135deg,#60a5fa,#22d3ee); color:#fff; }
|
||||||
|
.ad-btn.pri:hover { transform:translateY(-2px); box-shadow:0 8px 25px rgba(96,165,250,0.3); }
|
||||||
|
.ad-btn.sec { background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); color:#e2e8f0; }
|
||||||
|
.ad-btn.sec:hover { background:rgba(255,255,255,0.08); border-color:rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
.ad-user { font-family:'JetBrains Mono',monospace; font-size:0.7rem; color:#475569; margin-top:1.5rem; }
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="ad-wrapper">
|
||||||
|
<div class="ad-bg"><div class="grid"></div><div class="mesh"></div></div>
|
||||||
|
|
||||||
|
<div class="ad-card">
|
||||||
|
<div class="glow"></div>
|
||||||
|
|
||||||
|
<div class="ad-icon"><i class="fa-solid fa-shield-halved"></i></div>
|
||||||
|
<div class="ad-code">403</div>
|
||||||
|
<h1 class="ad-title">Access Denied</h1>
|
||||||
|
<p class="ad-text">
|
||||||
|
You <strong>do not have permission</strong> to access this resource.
|
||||||
|
Your current role does not include the required privileges for this action.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ad-info">
|
||||||
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
|
<span>If you believe this is an error, please contact your system administrator to request the appropriate permissions for your role.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ad-actions">
|
||||||
|
<a href="javascript:history.back()" class="ad-btn sec"><i class="fa-solid fa-arrow-left"></i> Go Back</a>
|
||||||
|
<a href="@Url.Action("Index", "Dashboard", new { area = "Admin" })" class="ad-btn pri"><i class="fa-solid fa-gauge-high"></i> Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ad-user">
|
||||||
|
<i class="fa-solid fa-user"></i> Logged in as: @User.Identity?.Name
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -2,748 +2,308 @@
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Delete Questionnaire";
|
ViewData["Title"] = "Delete Questionnaire";
|
||||||
|
var totalAnswers = Model.Questions?.Sum(q => q.Answers?.Count ?? 0) ?? 0;
|
||||||
|
var otherCount = Model.Questions?.Count(q => q.Answers != null && q.Answers.Any(a => a.IsOtherOption)) ?? 0;
|
||||||
|
var qCount = Model.Questions?.Count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Modern Design System */
|
|
||||||
@@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
@@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--danger-color: #ef4444;
|
--bg:#0f1923;--bg-card:#1a2332;--bg-elevated:#1f2b3d;--bg-input:#16202e;
|
||||||
--danger-light: #fca5a5;
|
--text-primary:#e8edf2;--text-secondary:#9ba8b9;--text-muted:#5e6e82;--text-faint:#3d4e63;
|
||||||
--danger-dark: #dc2626;
|
--teal:#33b3ae;--teal-soft:rgba(51,179,174,0.1);
|
||||||
--warning-color: #f59e0b;
|
--red:#ef4444;--red-soft:rgba(239,68,68,0.08);--red-medium:rgba(239,68,68,0.15);--red-border:rgba(239,68,68,0.3);--red-dark:#dc2626;
|
||||||
--info-color: #06b6d4;
|
--amber:#f59e0b;--amber-soft:rgba(245,158,11,0.08);--amber-border:rgba(245,158,11,0.3);
|
||||||
--success-color: #10b981;
|
--info:#06b6d4;--info-soft:rgba(6,182,212,0.1);--info-border:rgba(6,182,212,0.3);
|
||||||
--gray-50: #f8fafc;
|
--green:#10b981;--green-soft:rgba(16,185,129,0.1);--green-border:rgba(16,185,129,0.3);
|
||||||
--gray-100: #f1f5f9;
|
--border:rgba(255,255,255,0.06);--border-light:rgba(255,255,255,0.04);
|
||||||
--gray-200: #e2e8f0;
|
--shadow-sm:0 2px 8px rgba(0,0,0,0.25);--shadow-md:0 4px 16px rgba(0,0,0,0.3);--shadow-lg:0 8px 32px rgba(0,0,0,0.35);
|
||||||
--gray-300: #cbd5e1;
|
--radius-sm:8px;--radius-md:12px;--radius-lg:16px;
|
||||||
--gray-400: #94a3b8;
|
--transition:all 0.2s cubic-bezier(0.4,0,0.2,1);
|
||||||
--gray-500: #64748b;
|
|
||||||
--gray-600: #475569;
|
|
||||||
--gray-700: #334155;
|
|
||||||
--gray-800: #1e293b;
|
|
||||||
--gray-900: #0f172a;
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
|
||||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
|
||||||
--border-radius-sm: 8px;
|
|
||||||
--border-radius-md: 12px;
|
|
||||||
--border-radius-lg: 16px;
|
|
||||||
--border-radius-xl: 24px;
|
|
||||||
}
|
}
|
||||||
|
@@keyframes slideUp{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
|
||||||
* {
|
@@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
@@keyframes shake{0%,100%{transform:translateX(0)}10%,30%,50%,70%,90%{transform:translateX(-4px)}20%,40%,60%,80%{transform:translateX(4px)}}
|
||||||
}
|
*{box-sizing:border-box}
|
||||||
|
|
||||||
|
.delete-page{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);min-height:100vh;padding:32px 40px 80px;-webkit-font-smoothing:antialiased;color:var(--text-primary)}
|
||||||
|
|
||||||
.container {
|
/* ===== PAGE HEADER ===== */
|
||||||
max-width: 1000px;
|
.page-top{max-width:900px;margin:0 auto 28px;animation:slideUp .5s ease both}
|
||||||
}
|
.page-breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-muted);margin-bottom:16px}
|
||||||
|
.page-breadcrumb a{color:var(--text-muted);text-decoration:none;transition:var(--transition)}
|
||||||
/* Main Delete Card */
|
.page-breadcrumb a:hover{color:var(--teal)}
|
||||||
.delete-card {
|
.page-breadcrumb .sep{color:var(--text-faint)}
|
||||||
background: white;
|
.page-title-row{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||||
border-radius: var(--border-radius-xl);
|
.page-title{font-size:28px;font-weight:700;color:var(--text-primary);letter-spacing:-0.5px;margin:0;display:flex;align-items:center;gap:14px}
|
||||||
box-shadow: var(--shadow-2xl);
|
.page-title-icon{width:44px;height:44px;border-radius:var(--radius-md);background:linear-gradient(135deg,var(--red),var(--red-dark));display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff;box-shadow:0 4px 20px rgba(239,68,68,0.3)}
|
||||||
border: 1px solid var(--gray-200);
|
.btn-back{display:inline-flex;align-items:center;gap:8px;padding:10px 22px;background:var(--bg-card);color:var(--text-secondary);border:1.5px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-weight:500;font-family:inherit;cursor:pointer;transition:var(--transition);text-decoration:none}
|
||||||
overflow: hidden;
|
.btn-back:hover{background:var(--bg-elevated);border-color:rgba(255,255,255,0.1);color:var(--text-primary);text-decoration:none}
|
||||||
position: relative;
|
|
||||||
}
|
/* ===== DANGER BANNER ===== */
|
||||||
|
.danger-banner{max-width:900px;margin:0 auto 24px;background:linear-gradient(135deg,rgba(239,68,68,0.08),rgba(220,38,38,0.04));border:1.5px solid var(--red-border);border-radius:var(--radius-lg);padding:24px 28px;animation:slideUp .5s ease .06s both;position:relative;overflow:hidden}
|
||||||
.delete-card::before {
|
.danger-banner::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--red),var(--red-dark),var(--red))}
|
||||||
content: '';
|
.danger-banner-inner{display:flex;align-items:flex-start;gap:16px}
|
||||||
position: absolute;
|
.danger-banner-icon{width:44px;height:44px;border-radius:var(--radius-md);background:var(--red-medium);display:flex;align-items:center;justify-content:center;font-size:20px;color:var(--red);flex-shrink:0}
|
||||||
top: 0;
|
.danger-banner-content h3{font-size:16px;font-weight:700;color:var(--red);margin:0 0 10px}
|
||||||
left: 0;
|
.danger-list{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||||
right: 0;
|
.danger-list li{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-secondary);font-weight:500}
|
||||||
height: 6px;
|
.danger-list li i{color:var(--red);font-size:12px;flex-shrink:0}
|
||||||
background: linear-gradient(90deg, var(--danger-color), var(--danger-light), var(--danger-dark));
|
|
||||||
}
|
/* ===== INFO CARD ===== */
|
||||||
|
.info-card{max-width:900px;margin:0 auto 24px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-sm);animation:slideUp .5s ease .12s both;transition:var(--transition)}
|
||||||
/* Header Section */
|
.info-card:hover{box-shadow:var(--shadow-md);border-color:rgba(255,255,255,0.08)}
|
||||||
.delete-header {
|
.info-card-header{display:flex;align-items:center;gap:12px;padding:22px 28px;border-bottom:1px solid var(--border-light)}
|
||||||
background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-dark) 100%);
|
.info-card-icon{width:36px;height:36px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;font-size:16px;color:#fff}
|
||||||
color: white;
|
.info-card-icon.info-blue{background:var(--info)}
|
||||||
padding: 3rem 2rem;
|
.info-card-title{font-size:16px;font-weight:700;color:var(--text-primary);margin:0}
|
||||||
text-align: center;
|
.info-card-body{padding:24px 28px}
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
|
||||||
}
|
.detail-item{padding:16px 20px;background:var(--bg-elevated);border:1px solid var(--border-light);border-radius:var(--radius-sm);transition:var(--transition)}
|
||||||
|
.detail-item:hover{border-color:rgba(255,255,255,0.08)}
|
||||||
.delete-header::before {
|
.detail-item.full{grid-column:1/-1}
|
||||||
content: '';
|
.detail-label{font-size:11px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.6px;margin-bottom:8px;display:flex;align-items:center;gap:6px}
|
||||||
position: absolute;
|
.detail-label i{font-size:12px;color:var(--teal)}
|
||||||
top: 0;
|
.detail-value{font-size:15px;font-weight:600;color:var(--text-primary);line-height:1.4;word-break:break-word}
|
||||||
left: 0;
|
.detail-value.id-val{color:var(--teal);font-family:'JetBrains Mono',monospace}
|
||||||
right: 0;
|
.detail-value.desc-val{font-size:14px;font-weight:400;color:var(--text-secondary);line-height:1.7}
|
||||||
bottom: 0;
|
.empty-desc{color:var(--text-faint);font-style:italic}
|
||||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="0%" r="100%"><stop offset="0%" stop-color="white" stop-opacity="0.1"/><stop offset="100%" stop-color="white" stop-opacity="0"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
|
|
||||||
opacity: 0.3;
|
/* ===== STATS BAR ===== */
|
||||||
}
|
.stats-bar{max-width:900px;margin:0 auto 24px;display:grid;grid-template-columns:repeat(3,1fr);gap:16px;animation:slideUp .5s ease .18s both}
|
||||||
|
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:20px 24px;text-align:center;transition:var(--transition);position:relative;overflow:hidden}
|
||||||
.delete-icon {
|
.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;border-radius:3px 3px 0 0}
|
||||||
background: rgba(255, 255, 255, 0.2);
|
.stat-card:nth-child(1)::before{background:var(--red)}
|
||||||
width: 80px;
|
.stat-card:nth-child(2)::before{background:var(--amber)}
|
||||||
height: 80px;
|
.stat-card:nth-child(3)::before{background:var(--info)}
|
||||||
border-radius: 50%;
|
.stat-card:hover{box-shadow:var(--shadow-sm);border-color:rgba(255,255,255,0.1);transform:translateY(-2px)}
|
||||||
display: flex;
|
.stat-number{font-size:32px;font-weight:800;line-height:1;margin-bottom:6px}
|
||||||
align-items: center;
|
.stat-card:nth-child(1) .stat-number{color:var(--red)}
|
||||||
justify-content: center;
|
.stat-card:nth-child(2) .stat-number{color:var(--amber)}
|
||||||
margin: 0 auto 1.5rem;
|
.stat-card:nth-child(3) .stat-number{color:var(--info)}
|
||||||
position: relative;
|
.stat-label{font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.6px}
|
||||||
z-index: 1;
|
|
||||||
}
|
/* ===== ACTION ROW ===== */
|
||||||
|
.action-row{max-width:900px;margin:0 auto;animation:slideUp .5s ease .24s both}
|
||||||
.delete-icon i {
|
.action-card{background:var(--bg-card);border:1.5px solid var(--red-border);border-radius:var(--radius-lg);padding:28px;display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||||
font-size: 2.5rem;
|
.action-info{display:flex;align-items:center;gap:14px}
|
||||||
color: white;
|
.action-icon{width:48px;height:48px;border-radius:var(--radius-md);background:var(--red-medium);display:flex;align-items:center;justify-content:center;font-size:22px;color:var(--red);flex-shrink:0;animation:pulse 2s ease infinite}
|
||||||
}
|
.action-text h3{font-size:16px;font-weight:700;color:var(--text-primary);margin:0 0 4px}
|
||||||
|
.action-text p{font-size:13px;color:var(--text-muted);margin:0}
|
||||||
.delete-title {
|
.action-btns{display:flex;gap:12px}
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 800;
|
.btn-delete{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;background:linear-gradient(135deg,var(--red),var(--red-dark));color:#fff;border:none;border-radius:var(--radius-sm);font-size:14px;font-weight:600;font-family:inherit;cursor:pointer;transition:var(--transition);box-shadow:0 4px 20px rgba(239,68,68,0.25);text-decoration:none}
|
||||||
margin: 0 0 0.5rem;
|
.btn-delete:hover{background:linear-gradient(135deg,var(--red-dark),#b91c1c);transform:translateY(-2px);box-shadow:0 8px 28px rgba(239,68,68,0.35);color:#fff;text-decoration:none}
|
||||||
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
.btn-delete.loading{opacity:0.7;pointer-events:none}
|
||||||
position: relative;
|
.btn-delete.loading::after{content:'';width:16px;height:16px;margin-left:8px;border:2px solid transparent;border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
|
||||||
z-index: 1;
|
@@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
}
|
|
||||||
|
.btn-cancel{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;background:var(--bg-elevated);color:var(--text-secondary);border:1.5px solid var(--border);border-radius:var(--radius-sm);font-size:14px;font-weight:600;font-family:inherit;cursor:pointer;transition:var(--transition);text-decoration:none}
|
||||||
.delete-subtitle {
|
.btn-cancel:hover{background:var(--bg-hover);border-color:rgba(255,255,255,0.1);color:var(--text-primary);text-decoration:none}
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.9;
|
/* ===== MODAL ===== */
|
||||||
font-weight: 500;
|
.modal-content{background:var(--bg-card) !important;border:1px solid var(--border) !important;border-radius:var(--radius-lg) !important;box-shadow:0 25px 60px rgba(0,0,0,0.5) !important;overflow:hidden}
|
||||||
position: relative;
|
.modal-backdrop.show{backdrop-filter:blur(4px)}
|
||||||
z-index: 1;
|
|
||||||
}
|
.m-header{background:linear-gradient(135deg,var(--red),var(--red-dark));padding:24px 28px;border:none;display:flex;align-items:center;gap:12px}
|
||||||
|
.m-header-icon{width:40px;height:40px;border-radius:10px;background:rgba(255,255,255,0.15);display:flex;align-items:center;justify-content:center;font-size:18px;color:#fff}
|
||||||
/* Content Section */
|
.m-header h5{font-size:18px;font-weight:700;color:#fff;margin:0}
|
||||||
.delete-content {
|
|
||||||
padding: 3rem 2rem;
|
.m-body{padding:28px;text-align:center}
|
||||||
}
|
.m-body p.m-msg{font-size:16px;color:var(--text-primary);margin-bottom:20px;line-height:1.6;font-weight:500}
|
||||||
|
.m-warning{background:var(--amber-soft);border:1px solid var(--amber-border);border-radius:var(--radius-sm);padding:16px;margin-bottom:0;display:flex;align-items:center;gap:10px;font-size:13px;color:var(--amber);font-weight:600}
|
||||||
/* Warning Alert */
|
.m-warning i{font-size:18px;flex-shrink:0}
|
||||||
.warning-alert {
|
|
||||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
.m-footer{padding:20px 28px;border-top:1px solid var(--border);display:flex;gap:12px;justify-content:flex-end}
|
||||||
border: 2px solid var(--warning-color);
|
|
||||||
border-radius: var(--border-radius-lg);
|
.m-success{display:flex;align-items:center;justify-content:center;gap:10px;font-size:18px;font-weight:700;color:var(--green)}
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2.5rem;
|
/* ===== RESPONSIVE ===== */
|
||||||
position: relative;
|
@@media(max-width:768px){
|
||||||
}
|
.delete-page{padding:16px}
|
||||||
|
.detail-grid{grid-template-columns:1fr}
|
||||||
.warning-alert::before {
|
.stats-bar{grid-template-columns:1fr}
|
||||||
content: "⚠️";
|
.danger-list{grid-template-columns:1fr}
|
||||||
position: absolute;
|
.action-card{flex-direction:column;text-align:center}
|
||||||
top: -15px;
|
.action-info{flex-direction:column}
|
||||||
left: 20px;
|
.action-btns{flex-direction:column;width:100%}
|
||||||
background: var(--warning-color);
|
.action-btns .btn-delete,.action-btns .btn-cancel{width:100%;justify-content:center}
|
||||||
color: white;
|
.page-title-row{flex-direction:column;align-items:flex-start}
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-content {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--gray-800);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list li i {
|
|
||||||
color: var(--danger-color);
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Questionnaire Info */
|
|
||||||
.questionnaire-info {
|
|
||||||
background: linear-gradient(135deg, var(--gray-50) 0%, var(--gray-100) 100%);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
border: 1px solid var(--gray-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-icon {
|
|
||||||
background: var(--info-color);
|
|
||||||
color: white;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 1rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--gray-800);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item {
|
|
||||||
background: white;
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
border: 1px solid var(--gray-200);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gray-500);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gray-800);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-description {
|
|
||||||
background: white;
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
border: 1px solid var(--gray-200);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
max-height: 120px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-description-content {
|
|
||||||
color: var(--gray-700);
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats Section */
|
|
||||||
.stats-section {
|
|
||||||
background: white;
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
border: 1px solid var(--gray-200);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--gray-800);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-title i {
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
color: var(--info-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: linear-gradient(135deg, var(--gray-50) 0%, var(--gray-100) 100%);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid var(--gray-200);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--danger-color);
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gray-600);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action Buttons */
|
|
||||||
.action-section {
|
|
||||||
background: var(--gray-50);
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
border-top: 1px solid var(--gray-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 140px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
|
||||||
transition: left 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover::before {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger-custom {
|
|
||||||
background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-dark) 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger-custom:hover {
|
|
||||||
background: linear-gradient(135deg, var(--danger-dark) 0%, #b91c1c 100%);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary-custom {
|
|
||||||
background: linear-gradient(135deg, var(--gray-500) 0%, var(--gray-600) 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary-custom:hover {
|
|
||||||
background: linear-gradient(135deg, var(--gray-600) 0%, var(--gray-700) 100%);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced Modal */
|
|
||||||
.modal-content {
|
|
||||||
border-radius: var(--border-radius-xl);
|
|
||||||
border: none;
|
|
||||||
box-shadow: var(--shadow-2xl);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header-danger {
|
|
||||||
background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-dark) 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 2rem 2.5rem;
|
|
||||||
border: none;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title-custom {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body-custom {
|
|
||||||
padding: 2.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-message {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--gray-700);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-warning {
|
|
||||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
|
||||||
border: 1px solid var(--warning-color);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer-custom {
|
|
||||||
padding: 2rem 2.5rem;
|
|
||||||
border: none;
|
|
||||||
background: var(--gray-50);
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Success State */
|
|
||||||
.success-message {
|
|
||||||
color: var(--success-color);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading State */
|
|
||||||
.btn.loading {
|
|
||||||
opacity: 0.7;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.loading::after {
|
|
||||||
content: "";
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-top-color: currentColor;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@keyframes spin {
|
|
||||||
0%
|
|
||||||
|
|
||||||
{
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Design */
|
|
||||||
@@media (max-width: 768px) {
|
|
||||||
.container
|
|
||||||
|
|
||||||
{
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-content {
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@keyframes fadeIn {
|
|
||||||
from
|
|
||||||
|
|
||||||
{
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="delete-page">
|
||||||
<div class="delete-card fade-in">
|
|
||||||
<!-- Header Section -->
|
<!-- Page Header -->
|
||||||
<div class="delete-header">
|
<div class="page-top">
|
||||||
<div class="delete-icon">
|
<div class="page-breadcrumb">
|
||||||
<i class="bi bi-trash3-fill"></i>
|
<a href="@Url.Action("Index", "Questionnaire")">Questionnaires</a>
|
||||||
</div>
|
<span class="sep">/</span>
|
||||||
<h1 class="delete-title">Delete Questionnaire</h1>
|
<span>Delete</span>
|
||||||
<p class="delete-subtitle">This action will permanently remove this questionnaire and all associated data</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="page-title-row">
|
||||||
<!-- Content Section -->
|
<h1 class="page-title">
|
||||||
<div class="delete-content">
|
<span class="page-title-icon"><i class="bi bi-trash3-fill"></i></span>
|
||||||
<!-- Warning Alert -->
|
Delete Questionnaire
|
||||||
<div class="warning-alert">
|
</h1>
|
||||||
<div class="warning-content">
|
<a href="@Url.Action("Index", "Questionnaire")" class="btn-back"><i class="bi bi-arrow-left"></i> Back to List</a>
|
||||||
<h3 class="warning-title">⚠️ Critical Warning</h3>
|
|
||||||
<ul class="warning-list">
|
|
||||||
<li><i class="bi bi-x-circle-fill"></i>This action cannot be undone</li>
|
|
||||||
<li><i class="bi bi-x-circle-fill"></i>All questions and answers will be permanently deleted</li>
|
|
||||||
<li><i class="bi bi-x-circle-fill"></i>Any survey responses will be lost forever</li>
|
|
||||||
<li><i class="bi bi-x-circle-fill"></i>Associated data and analytics will be removed</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Questionnaire Information -->
|
|
||||||
<div class="questionnaire-info">
|
|
||||||
<div class="info-header">
|
|
||||||
<div class="info-icon">
|
|
||||||
<i class="bi bi-file-text"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="info-title">Questionnaire Details</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Title</div>
|
|
||||||
<div class="info-value">@Model.Title</div>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<div class="info-label">Questionnaire ID</div>
|
|
||||||
<div class="info-value">#@Model.Id</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-description">
|
|
||||||
<div class="info-label">Description</div>
|
|
||||||
<div class="info-description-content">
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Description))
|
|
||||||
{
|
|
||||||
@Html.Raw(Model.Description)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<em class="text-muted">No description provided</em>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistics -->
|
|
||||||
<div class="stats-section">
|
|
||||||
<h3 class="stats-title">
|
|
||||||
<i class="bi bi-graph-up"></i>
|
|
||||||
Content Statistics
|
|
||||||
</h3>
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-number">@Model.Questions.Count</span>
|
|
||||||
<span class="stat-label">Questions</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-number">@Model.Questions.Sum(q => q.Answers.Count)</span>
|
|
||||||
<span class="stat-label">Total Answers</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<span class="stat-number">@Model.Questions.Count(q => q.Answers.Any(a => a.IsOtherOption))</span>
|
|
||||||
<span class="stat-label">Other Options</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Action Section -->
|
<!-- Danger Banner -->
|
||||||
<div class="action-section">
|
<div class="danger-banner">
|
||||||
<div class="action-buttons">
|
<div class="danger-banner-inner">
|
||||||
<button type="button" class="btn btn-danger-custom" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
<div class="danger-banner-icon"><i class="bi bi-exclamation-triangle-fill"></i></div>
|
||||||
<i class="bi bi-trash3-fill"></i>
|
<div class="danger-banner-content">
|
||||||
Delete Questionnaire
|
<h3>This action is permanent and cannot be undone</h3>
|
||||||
</button>
|
<ul class="danger-list">
|
||||||
<a asp-action="Index" class="btn btn-secondary-custom">
|
<li><i class="bi bi-x-circle-fill"></i> All questions will be permanently deleted</li>
|
||||||
<i class="bi bi-arrow-left"></i>
|
<li><i class="bi bi-x-circle-fill"></i> All answers will be removed</li>
|
||||||
Back to List
|
<li><i class="bi bi-x-circle-fill"></i> Survey responses will be lost forever</li>
|
||||||
</a>
|
<li><i class="bi bi-x-circle-fill"></i> Associated analytics will be erased</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Questionnaire Info -->
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-card-header">
|
||||||
|
<div class="info-card-icon info-blue"><i class="bi bi-file-text-fill"></i></div>
|
||||||
|
<h2 class="info-card-title">Questionnaire Details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="info-card-body">
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label"><i class="bi bi-hash"></i> ID</div>
|
||||||
|
<div class="detail-value id-val">#@Model.Id</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label"><i class="bi bi-card-heading"></i> Title</div>
|
||||||
|
<div class="detail-value">@Model.Title</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item full">
|
||||||
|
<div class="detail-label"><i class="bi bi-card-text"></i> Description</div>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Description))
|
||||||
|
{
|
||||||
|
<div class="detail-value desc-val">@Html.Raw(Model.Description)</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="detail-value empty-desc">No description provided</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">@qCount</div>
|
||||||
|
<div class="stat-label">Questions</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">@totalAnswers</div>
|
||||||
|
<div class="stat-label">Total Answers</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">@otherCount</div>
|
||||||
|
<div class="stat-label">Other Options</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Row -->
|
||||||
|
<div class="action-row">
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-info">
|
||||||
|
<div class="action-icon"><i class="bi bi-trash3"></i></div>
|
||||||
|
<div class="action-text">
|
||||||
|
<h3>Ready to delete this questionnaire?</h3>
|
||||||
|
<p>This will permanently remove all @qCount questions and @totalAnswers answers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-btns">
|
||||||
|
<a href="@Url.Action("Index", "Questionnaire")" class="btn-cancel"><i class="bi bi-arrow-left"></i> Cancel</a>
|
||||||
|
<button type="button" class="btn-delete" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||||
|
<i class="bi bi-trash3-fill"></i> Delete Questionnaire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Enhanced Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header-danger">
|
<div class="m-header">
|
||||||
<h5 class="modal-title-custom" id="deleteModalLabel">
|
<div class="m-header-icon"><i class="bi bi-exclamation-triangle-fill"></i></div>
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
<h5 id="deleteModalLabel">Confirm Deletion</h5>
|
||||||
Confirm Deletion
|
|
||||||
</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body-custom">
|
<div class="m-body">
|
||||||
<p class="modal-message" id="deleteMessage">
|
<p class="m-msg" id="deleteMessage">Are you absolutely sure you want to delete this questionnaire?</p>
|
||||||
Are you absolutely sure you want to delete this questionnaire?
|
<div class="m-warning" id="modalWarning">
|
||||||
</p>
|
|
||||||
<div class="modal-warning">
|
|
||||||
<i class="bi bi-shield-exclamation"></i>
|
<i class="bi bi-shield-exclamation"></i>
|
||||||
<strong>This action is irreversible!</strong> Once deleted, this questionnaire and all its data cannot be recovered.
|
<span>This action is irreversible. All data will be permanently lost.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer-custom">
|
<div class="m-footer" id="modalFooter">
|
||||||
<button type="button" class="btn btn-secondary-custom" data-bs-dismiss="modal">
|
<button type="button" class="btn-cancel" data-bs-dismiss="modal"><i class="bi bi-x"></i> Cancel</button>
|
||||||
<i class="bi bi-x"></i>
|
<button type="button" class="btn-delete" id="deleteButton"><i class="bi bi-trash3-fill"></i> Yes, Delete Forever</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger-custom" id="deleteButton">
|
|
||||||
<i class="bi bi-trash3-fill"></i>
|
|
||||||
Yes, Delete Forever
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ckeditor/4.11.4/ckeditor.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
var itemId = @Model.Id;
|
var itemId = @Model.Id;
|
||||||
|
|
||||||
// Enhanced delete button click event
|
|
||||||
$('#deleteButton').on('click', function () {
|
$('#deleteButton').on('click', function () {
|
||||||
var $btn = $(this);
|
var $btn = $(this);
|
||||||
var $modal = $('#deleteModal');
|
|
||||||
|
|
||||||
// Add loading state
|
|
||||||
$btn.addClass('loading').prop('disabled', true);
|
$btn.addClass('loading').prop('disabled', true);
|
||||||
|
|
||||||
// Make AJAX request to delete the item
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/admin/Questionnaire/Delete/' + itemId,
|
url: '/admin/Questionnaire/Delete/' + itemId,
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
success: function (result) {
|
success: function (result) {
|
||||||
// Update modal content to show success
|
// Success state
|
||||||
$('.modal-header-danger').removeClass('modal-header-danger').css({
|
$('.m-header').css('background', 'linear-gradient(135deg, #10b981, #059669)');
|
||||||
'background': 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
$('.m-header h5').text('Deleted Successfully');
|
||||||
});
|
$('.m-header-icon i').removeClass('bi-exclamation-triangle-fill').addClass('bi-check-circle-fill');
|
||||||
|
$('#deleteMessage').html('<div class="m-success"><i class="bi bi-check-circle-fill"></i> Questionnaire deleted successfully!</div>');
|
||||||
|
$('#modalWarning').hide();
|
||||||
|
$('#modalFooter').hide();
|
||||||
|
|
||||||
$('.modal-title-custom').html('<i class="bi bi-check-circle-fill"></i> Success');
|
|
||||||
|
|
||||||
$('#deleteMessage').html('<div class="success-message"><i class="bi bi-check-circle-fill"></i>Questionnaire deleted successfully!</div>');
|
|
||||||
|
|
||||||
$('.modal-warning').hide();
|
|
||||||
$('.modal-footer-custom').hide();
|
|
||||||
|
|
||||||
// Automatically redirect after showing success
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
$modal.modal('hide');
|
$('#deleteModal').modal('hide');
|
||||||
window.location.href = '/admin/Questionnaire/Index';
|
window.location.href = '/admin/Questionnaire/Index';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
},
|
},
|
||||||
error: function (xhr, status, error) {
|
error: function () {
|
||||||
// Remove loading state
|
|
||||||
$btn.removeClass('loading').prop('disabled', false);
|
$btn.removeClass('loading').prop('disabled', false);
|
||||||
|
$('#deleteMessage').html('<span style="color:var(--red)"><i class="bi bi-exclamation-triangle-fill"></i> Failed to delete. Please try again.</span>');
|
||||||
// Show error message
|
|
||||||
$('#deleteMessage').html('<div class="text-danger"><i class="bi bi-exclamation-triangle-fill"></i> Failed to delete questionnaire. Please try again.</div>');
|
|
||||||
|
|
||||||
// Auto-hide error message after 3 seconds
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
$modal.modal('hide');
|
$('#deleteModal').modal('hide');
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset modal when hidden
|
|
||||||
$('#deleteModal').on('hidden.bs.modal', function () {
|
$('#deleteModal').on('hidden.bs.modal', function () {
|
||||||
// Reset button state
|
|
||||||
$('#deleteButton').removeClass('loading').prop('disabled', false);
|
$('#deleteButton').removeClass('loading').prop('disabled', false);
|
||||||
|
$('.m-header').css('background', 'linear-gradient(135deg, #ef4444, #dc2626)');
|
||||||
// Reset modal content if needed
|
$('.m-header h5').text('Confirm Deletion');
|
||||||
$('.modal-header-danger').css({
|
$('.m-header-icon i').removeClass('bi-check-circle-fill').addClass('bi-exclamation-triangle-fill');
|
||||||
'background': 'linear-gradient(135deg, var(--danger-color) 0%, var(--danger-dark) 100%)'
|
|
||||||
});
|
|
||||||
|
|
||||||
$('.modal-title-custom').html('<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion');
|
|
||||||
$('#deleteMessage').text('Are you absolutely sure you want to delete this questionnaire?');
|
$('#deleteMessage').text('Are you absolutely sure you want to delete this questionnaire?');
|
||||||
$('.modal-warning').show();
|
$('#modalWarning').show();
|
||||||
$('.modal-footer-custom').show();
|
$('#modalFooter').show();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,58 +1,746 @@
|
||||||
@model IEnumerable<RoleViewModel>
|
@* Views/Admin/Roles/Index.cshtml *@
|
||||||
|
@model IEnumerable<RoleViewModel>
|
||||||
|
|
||||||
|
@using Web.ViewModel.AccountVM
|
||||||
|
@using Web.Authorization
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Index";
|
ViewData["Title"] = "Role Management";
|
||||||
|
var permissionGroups = ViewBag.PermissionGroups as Dictionary<string, List<PermissionItem>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
@@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--nex-orange: #fb923c;
|
||||||
|
--nex-amber: #fbbf24;
|
||||||
|
--nex-yellow: #facc15;
|
||||||
|
--nex-blue: #60a5fa;
|
||||||
|
--nex-cyan: #22d3ee;
|
||||||
|
--nex-purple: #c084fc;
|
||||||
|
--nex-red: #f87171;
|
||||||
|
--nex-emerald: #34d399;
|
||||||
|
--nex-teal: #14b8a6;
|
||||||
|
--dark-900: #0f172a;
|
||||||
|
--dark-800: #1e293b;
|
||||||
|
--dark-700: #334155;
|
||||||
|
--dark-600: #475569;
|
||||||
|
--dark-500: #64748b;
|
||||||
|
--dark-400: #94a3b8;
|
||||||
|
--dark-300: #cbd5e1;
|
||||||
|
--glass-bg: rgba(255,255,255,0.04);
|
||||||
|
--glass-border: rgba(255,255,255,0.08);
|
||||||
|
--font-main: 'Space Grotesk', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
<div class="container mt-5">
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
<partial name="_Notification" />
|
body { font-family:var(--font-main); background:var(--dark-900); color:#e2e8f0; overflow-x:hidden; }
|
||||||
|
|
||||||
<div class="card bg-default mb-3">
|
.nex-bg { position:fixed; inset:0; z-index:-1; overflow:hidden; }
|
||||||
<div class="card-header">Roles</div>
|
.nex-bg .grid { position:absolute; inset:0; background-image:linear-gradient(rgba(251,146,60,0.04) 1px,transparent 1px),linear-gradient(90deg,rgba(251,146,60,0.04) 1px,transparent 1px); background-size:60px 60px; }
|
||||||
<div class="card-body">
|
.nex-bg .mesh { position:absolute; inset:0; background:radial-gradient(circle at 15% 25%,rgba(251,146,60,0.08) 0%,transparent 50%),radial-gradient(circle at 85% 55%,rgba(192,132,252,0.06) 0%,transparent 50%),radial-gradient(circle at 50% 85%,rgba(96,165,250,0.05) 0%,transparent 50%); }
|
||||||
<p>
|
|
||||||
<a asp-action="Create" class="btn btn-primary">Create New</a>
|
.page-header { position:relative; z-index:10; padding:2.5rem 0 1.5rem; }
|
||||||
</p>
|
.container { max-width:1400px; margin:0 auto; padding:0 2rem; }
|
||||||
<h4 class="card-title">Roles List</h4>
|
|
||||||
<form asp-action="DeleteMultiple" method="post">
|
.breadcrumb-nex { display:flex; align-items:center; gap:0.75rem; font-family:var(--font-mono); font-size:0.72rem; text-transform:uppercase; letter-spacing:0.06em; margin-bottom:1.5rem; flex-wrap:wrap; }
|
||||||
<table class="table table-responsive w-100 d-block d-md-table">
|
.breadcrumb-nex a { color:var(--dark-400); text-decoration:none; transition:color .2s; }
|
||||||
<thead class="w-100">
|
.breadcrumb-nex a:hover { color:var(--nex-orange); }
|
||||||
<tr>
|
.breadcrumb-nex .sep { color:var(--dark-600); font-size:0.55rem; }
|
||||||
<th>
|
.breadcrumb-nex .current { color:var(--nex-orange); }
|
||||||
<input type="checkbox" onclick="selectAll(this)" title="Select/Deselect All" />
|
|
||||||
</th>
|
.header-row { display:flex; align-items:flex-start; justify-content:space-between; gap:2rem; flex-wrap:wrap; }
|
||||||
<th>Role Name</th>
|
.header-badge { display:inline-flex; align-items:center; gap:0.5rem; padding:0.4rem 1rem; background:rgba(251,146,60,0.1); border:1px solid rgba(251,146,60,0.25); border-radius:50px; font-family:var(--font-mono); font-size:0.65rem; font-weight:600; color:var(--nex-orange); letter-spacing:0.1em; margin-bottom:1rem; }
|
||||||
<th class="text-end">Action</th>
|
.header-badge .dot { width:6px; height:6px; border-radius:50%; background:var(--nex-orange); animation:pulse-dot 2s ease infinite; }
|
||||||
</tr>
|
@@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||||
</thead>
|
|
||||||
<tbody class="w-100">
|
.header-title { font-size:2.2rem; font-weight:700; color:#f8fafc; line-height:1.2; margin-bottom:0.5rem; }
|
||||||
@foreach (var item in Model)
|
.header-title .grad { background:linear-gradient(135deg, var(--nex-orange), var(--nex-amber)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||||
|
.header-sub { color:var(--dark-400); font-size:0.9rem; }
|
||||||
|
.header-actions { display:flex; gap:0.75rem; flex-wrap:wrap; padding-top:0.5rem; }
|
||||||
|
|
||||||
|
.h-btn { display:inline-flex; align-items:center; gap:0.5rem; padding:0.6rem 1.3rem; border-radius:10px; font-family:var(--font-main); font-size:0.82rem; font-weight:600; border:none; cursor:pointer; transition:all .25s; text-decoration:none; }
|
||||||
|
.h-btn.pri { background:linear-gradient(135deg, var(--nex-orange), var(--nex-amber)); color:#fff; }
|
||||||
|
.h-btn.pri:hover { transform:translateY(-2px); box-shadow:0 8px 25px rgba(251,146,60,0.3); }
|
||||||
|
.h-btn.sec { background:var(--glass-bg); border:1px solid var(--glass-border); color:#e2e8f0; }
|
||||||
|
.h-btn.sec:hover { background:rgba(251,146,60,0.1); border-color:rgba(251,146,60,0.3); color:var(--nex-orange); }
|
||||||
|
.h-btn.danger { background:rgba(248,113,113,0.15); border:1px solid rgba(248,113,113,0.3); color:var(--nex-red); }
|
||||||
|
.h-btn.danger:hover { background:rgba(248,113,113,0.25); transform:translateY(-2px); }
|
||||||
|
.h-btn:disabled { opacity:0.5; cursor:not-allowed; transform:none !important; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-row { display:grid; grid-template-columns:repeat(3,1fr); gap:1rem; margin-bottom:2rem; }
|
||||||
|
.stat-chip { background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:14px; padding:1.2rem 1.5rem; display:flex; align-items:center; gap:1rem; transition:all .3s; }
|
||||||
|
.stat-chip:hover { border-color:rgba(251,146,60,0.2); transform:translateY(-2px); }
|
||||||
|
.stat-chip .s-icon { width:44px; height:44px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; flex-shrink:0; }
|
||||||
|
.stat-chip .s-icon.orange { background:rgba(251,146,60,0.15); color:var(--nex-orange); }
|
||||||
|
.stat-chip .s-icon.purple { background:rgba(192,132,252,0.15); color:var(--nex-purple); }
|
||||||
|
.stat-chip .s-icon.blue { background:rgba(96,165,250,0.15); color:var(--nex-blue); }
|
||||||
|
.stat-chip .s-val { font-size:1.6rem; font-weight:700; color:#f8fafc; line-height:1; }
|
||||||
|
.stat-chip .s-lbl { font-size:0.72rem; color:var(--dark-400); text-transform:uppercase; letter-spacing:0.05em; font-family:var(--font-mono); margin-top:2px; }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.nex-card { background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:16px; backdrop-filter:blur(12px); position:relative; overflow:hidden; }
|
||||||
|
.nex-card .top-glow { position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg,transparent,var(--nex-orange),transparent); opacity:0.6; }
|
||||||
|
|
||||||
|
.tbl-head { display:flex; align-items:center; justify-content:space-between; padding:1.25rem 1.5rem; border-bottom:1px solid var(--glass-border); flex-wrap:wrap; gap:1rem; }
|
||||||
|
.tbl-title { display:flex; align-items:center; gap:0.75rem; font-size:1rem; font-weight:600; color:#f8fafc; }
|
||||||
|
.tbl-title i { color:var(--nex-orange); }
|
||||||
|
.tbl-count { font-family:var(--font-mono); font-size:0.7rem; color:var(--dark-400); background:rgba(251,146,60,0.1); padding:0.25rem 0.65rem; border-radius:6px; border:1px solid rgba(251,146,60,0.2); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.role-table { width:100%; border-collapse:collapse; }
|
||||||
|
.role-table thead th { padding:0.85rem 1.5rem; text-align:left; font-family:var(--font-mono); font-size:0.68rem; font-weight:600; color:var(--dark-400); text-transform:uppercase; letter-spacing:0.08em; border-bottom:1px solid var(--glass-border); background:rgba(255,255,255,0.02); }
|
||||||
|
.role-table thead th:first-child { width:50px; text-align:center; }
|
||||||
|
.role-table thead th:last-child { text-align:right; }
|
||||||
|
.role-table tbody tr { border-bottom:1px solid rgba(255,255,255,0.04); transition:background .2s; }
|
||||||
|
.role-table tbody tr:hover { background:rgba(251,146,60,0.04); }
|
||||||
|
.role-table tbody tr:last-child { border-bottom:none; }
|
||||||
|
.role-table tbody td { padding:0.9rem 1.5rem; font-size:0.88rem; vertical-align:middle; }
|
||||||
|
.role-table tbody td:first-child { text-align:center; }
|
||||||
|
.role-table tbody td:last-child { text-align:right; }
|
||||||
|
|
||||||
|
.nex-check { width:18px; height:18px; accent-color:var(--nex-orange); cursor:pointer; }
|
||||||
|
|
||||||
|
.role-icon { width:38px; height:38px; border-radius:10px; display:inline-flex; align-items:center; justify-content:center; font-size:1rem; margin-right:0.75rem; vertical-align:middle; }
|
||||||
|
.role-name { font-weight:600; color:#f1f5f9; }
|
||||||
|
|
||||||
|
/* Permission pills in table */
|
||||||
|
.perm-pill { display:inline-flex; align-items:center; gap:0.25rem; padding:0.2rem 0.55rem; border-radius:5px; font-size:0.65rem; font-weight:600; font-family:var(--font-mono); margin:0.15rem; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.08); color:var(--dark-300); }
|
||||||
|
.perm-pill.has { background:rgba(52,211,153,0.1); border-color:rgba(52,211,153,0.2); color:var(--nex-emerald); }
|
||||||
|
.perm-more { font-size:0.7rem; color:var(--dark-400); cursor:pointer; padding:0.2rem 0.5rem; border-radius:5px; background:rgba(255,255,255,0.05); }
|
||||||
|
.perm-more:hover { color:var(--nex-orange); }
|
||||||
|
|
||||||
|
.act-btn { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--dark-300); cursor:pointer; transition:all .2s; font-size:0.82rem; margin-left:0.35rem; }
|
||||||
|
.act-btn:hover { transform:translateY(-1px); }
|
||||||
|
.act-btn.edit:hover { background:rgba(96,165,250,0.15); border-color:rgba(96,165,250,0.3); color:var(--nex-blue); }
|
||||||
|
.act-btn.del:hover { background:rgba(248,113,113,0.15); border-color:rgba(248,113,113,0.3); color:var(--nex-red); }
|
||||||
|
|
||||||
|
.tbl-foot { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.5rem; border-top:1px solid var(--glass-border); flex-wrap:wrap; gap:0.75rem; }
|
||||||
|
.selected-info { font-family:var(--font-mono); font-size:0.72rem; color:var(--dark-400); }
|
||||||
|
.selected-info span { color:var(--nex-orange); font-weight:600; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { display:none; position:fixed; inset:0; z-index:9999; background:rgba(2,6,23,0.8); backdrop-filter:blur(8px); align-items:center; justify-content:center; padding:2rem; }
|
||||||
|
.modal-overlay.active { display:flex; animation:fadeIn .25s ease; }
|
||||||
|
@@keyframes fadeIn { from{opacity:0} to{opacity:1} }
|
||||||
|
|
||||||
|
.modal-box { background:var(--dark-800); border:1px solid var(--glass-border); border-radius:20px; width:100%; max-width:620px; max-height:90vh; overflow-y:auto; position:relative; animation:slideUp .3s ease; }
|
||||||
|
@@keyframes slideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
|
||||||
|
.modal-box .m-glow { position:absolute; top:0; left:0; right:0; height:3px; border-radius:20px 20px 0 0; }
|
||||||
|
.modal-box .m-glow.orange { background:linear-gradient(90deg,var(--nex-orange),var(--nex-amber)); }
|
||||||
|
.modal-box .m-glow.blue { background:linear-gradient(90deg,var(--nex-blue),var(--nex-cyan)); }
|
||||||
|
.modal-box .m-glow.red { background:linear-gradient(90deg,var(--nex-red),#dc2626); }
|
||||||
|
|
||||||
|
.m-header { display:flex; align-items:center; justify-content:space-between; padding:1.5rem 1.75rem 1rem; }
|
||||||
|
.m-header-left { display:flex; align-items:center; gap:0.85rem; }
|
||||||
|
.m-header-icon { width:42px; height:42px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; }
|
||||||
|
.m-header-icon.orange { background:rgba(251,146,60,0.15); color:var(--nex-orange); }
|
||||||
|
.m-header-icon.blue { background:rgba(96,165,250,0.15); color:var(--nex-blue); }
|
||||||
|
.m-header-icon.red { background:rgba(248,113,113,0.15); color:var(--nex-red); }
|
||||||
|
.m-header h3 { font-size:1.1rem; font-weight:700; color:#f8fafc; }
|
||||||
|
.m-header p { font-size:0.78rem; color:var(--dark-400); margin-top:2px; }
|
||||||
|
.m-close { width:36px; height:36px; border-radius:10px; border:1px solid var(--glass-border); background:none; color:var(--dark-400); display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all .2s; font-size:0.9rem; }
|
||||||
|
.m-close:hover { background:rgba(248,113,113,0.15); border-color:rgba(248,113,113,0.3); color:var(--nex-red); }
|
||||||
|
|
||||||
|
.m-body { padding:0.5rem 1.75rem 1.5rem; }
|
||||||
|
|
||||||
|
.f-group { margin-bottom:1.1rem; }
|
||||||
|
.f-label { display:block; font-family:var(--font-mono); font-size:0.7rem; font-weight:600; color:var(--dark-300); text-transform:uppercase; letter-spacing:0.06em; margin-bottom:0.45rem; }
|
||||||
|
.f-input { width:100%; padding:0.65rem 1rem; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:10px; color:#e2e8f0; font-family:var(--font-main); font-size:0.88rem; outline:none; transition:border-color .2s, box-shadow .2s; }
|
||||||
|
.f-input:focus { border-color:rgba(251,146,60,0.5); box-shadow:0 0 0 3px rgba(251,146,60,0.1); }
|
||||||
|
.f-input::placeholder { color:var(--dark-500); }
|
||||||
|
|
||||||
|
/* Permission groups */
|
||||||
|
.perm-groups { max-height:350px; overflow-y:auto; padding-right:0.5rem; }
|
||||||
|
.perm-groups::-webkit-scrollbar { width:4px; }
|
||||||
|
.perm-groups::-webkit-scrollbar-track { background:transparent; }
|
||||||
|
.perm-groups::-webkit-scrollbar-thumb { background:var(--dark-600); border-radius:4px; }
|
||||||
|
|
||||||
|
.perm-group { margin-bottom:1rem; }
|
||||||
|
.perm-group-head { display:flex; align-items:center; justify-content:space-between; padding:0.5rem 0.75rem; background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:10px 10px 0 0; cursor:pointer; }
|
||||||
|
.perm-group-head h5 { font-size:0.78rem; font-weight:700; color:var(--dark-200); display:flex; align-items:center; gap:0.5rem; }
|
||||||
|
.perm-group-head h5 i { color:var(--nex-orange); font-size:0.75rem; }
|
||||||
|
.perm-group-toggle { font-size:0.65rem; color:var(--nex-orange); cursor:pointer; font-family:var(--font-mono); font-weight:600; background:none; border:none; padding:0.2rem 0.5rem; border-radius:4px; }
|
||||||
|
.perm-group-toggle:hover { background:rgba(251,146,60,0.1); }
|
||||||
|
|
||||||
|
.perm-group-body { border:1px solid var(--glass-border); border-top:none; border-radius:0 0 10px 10px; padding:0.6rem 0.75rem; display:flex; flex-wrap:wrap; gap:0.4rem; }
|
||||||
|
|
||||||
|
.perm-chip { position:relative; }
|
||||||
|
.perm-chip input { position:absolute; opacity:0; pointer-events:none; }
|
||||||
|
.perm-chip label { display:inline-flex; align-items:center; gap:0.35rem; padding:0.35rem 0.7rem; border-radius:7px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--dark-400); font-size:0.72rem; font-weight:600; cursor:pointer; transition:all .2s; font-family:var(--font-mono); }
|
||||||
|
.perm-chip input:checked + label { background:rgba(251,146,60,0.15); border-color:rgba(251,146,60,0.4); color:var(--nex-orange); }
|
||||||
|
.perm-chip label:hover { border-color:rgba(251,146,60,0.3); }
|
||||||
|
.perm-chip label i { font-size:0.65rem; }
|
||||||
|
|
||||||
|
.m-footer { display:flex; align-items:center; justify-content:flex-end; gap:0.75rem; padding:1rem 1.75rem 1.5rem; border-top:1px solid var(--glass-border); }
|
||||||
|
|
||||||
|
.modal-errors { margin-bottom:1rem; padding:0.75rem 1rem; background:rgba(248,113,113,0.1); border:1px solid rgba(248,113,113,0.25); border-radius:10px; display:none; }
|
||||||
|
.modal-errors ul { list-style:none; padding:0; margin:0; }
|
||||||
|
.modal-errors li { font-size:0.8rem; color:var(--nex-red); padding:0.15rem 0; display:flex; align-items:center; gap:0.4rem; }
|
||||||
|
.modal-errors li i { font-size:0.7rem; }
|
||||||
|
|
||||||
|
.nex-toast { position:fixed; top:1.5rem; right:1.5rem; z-index:99999; padding:0.85rem 1.5rem; border-radius:12px; font-size:0.85rem; font-weight:600; display:flex; align-items:center; gap:0.6rem; animation:slideIn .3s ease,fadeOut .3s ease 2.7s forwards; pointer-events:none; }
|
||||||
|
.nex-toast.success { background:rgba(52,211,153,0.15); border:1px solid rgba(52,211,153,0.3); color:var(--nex-emerald); backdrop-filter:blur(12px); }
|
||||||
|
.nex-toast.error { background:rgba(248,113,113,0.15); border:1px solid rgba(248,113,113,0.3); color:var(--nex-red); backdrop-filter:blur(12px); }
|
||||||
|
@@keyframes slideIn { from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:translateX(0)} }
|
||||||
|
@@keyframes fadeOut { to{opacity:0;transform:translateY(-10px)} }
|
||||||
|
|
||||||
|
.del-warning { font-size:0.82rem; color:var(--dark-300); line-height:1.6; }
|
||||||
|
.del-warning strong { color:var(--nex-red); }
|
||||||
|
|
||||||
|
.btn-spinner { display:none; width:16px; height:16px; border:2px solid rgba(255,255,255,0.3); border-top-color:#fff; border-radius:50%; animation:spin .6s linear infinite; }
|
||||||
|
@@keyframes spin { to{transform:rotate(360deg)} }
|
||||||
|
|
||||||
|
.empty-state { text-align:center; padding:4rem 2rem; }
|
||||||
|
.empty-state i { font-size:3rem; color:var(--dark-600); margin-bottom:1rem; }
|
||||||
|
.empty-state h4 { color:var(--dark-300); font-size:1.1rem; margin-bottom:0.5rem; }
|
||||||
|
.empty-state p { color:var(--dark-500); font-size:0.85rem; }
|
||||||
|
|
||||||
|
@@media(max-width:768px) {
|
||||||
|
.header-row { flex-direction:column; }
|
||||||
|
.stats-row { grid-template-columns:1fr; }
|
||||||
|
.tbl-head { flex-direction:column; align-items:flex-start; }
|
||||||
|
.role-table { display:block; overflow-x:auto; }
|
||||||
|
.modal-box { max-width:100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="nex-bg"><div class="grid"></div><div class="mesh"></div></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<section class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb-nex">
|
||||||
|
<a href="@Url.Action("Index", "Dashboard")"><i class="fa-solid fa-gauge-high"></i> Admin</a>
|
||||||
|
<i class="fa-solid fa-chevron-right sep"></i>
|
||||||
|
<span class="current">Role Management</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-row">
|
||||||
|
<div>
|
||||||
|
<div class="header-badge"><span class="dot"></span> PERMISSION & ACCESS CONTROL</div>
|
||||||
|
<h1 class="header-title">Role <span class="grad">Management</span></h1>
|
||||||
|
<p class="header-sub">Define roles and assign granular permissions for system access</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="h-btn pri" onclick="if(checkPermission(rolePermissions.canCreate)) openCreateModal()">
|
||||||
|
<i class="fa-solid fa-plus"></i> New Role
|
||||||
|
</button>
|
||||||
|
<button type="button" class="h-btn danger d-none" id="bulkDeleteBtn"
|
||||||
|
onclick="if(checkPermission(rolePermissions.canDelete)) confirmBulkDelete()">
|
||||||
|
<i class="fa-solid fa-trash-can"></i> Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<section style="position:relative;z-index:10;padding:0 0 1.5rem">
|
||||||
|
<div class="container">
|
||||||
|
@{
|
||||||
|
var totalRoles = Model.Count();
|
||||||
|
var totalPerms = Permissions.GetAll().Count;
|
||||||
|
var avgPerms = totalRoles > 0 ? (int)Model.Average(r => r.SelectedPermissions?.Count ?? 0) : 0;
|
||||||
|
}
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-chip">
|
||||||
|
<div class="s-icon orange"><i class="fa-solid fa-shield-halved"></i></div>
|
||||||
|
<div><div class="s-val">@totalRoles</div><div class="s-lbl">Total Roles</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<div class="s-icon purple"><i class="fa-solid fa-key"></i></div>
|
||||||
|
<div><div class="s-val">@totalPerms</div><div class="s-lbl">Available Permissions</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<div class="s-icon blue"><i class="fa-solid fa-chart-bar"></i></div>
|
||||||
|
<div><div class="s-val">@avgPerms</div><div class="s-lbl">Avg Permissions / Role</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Roles Table -->
|
||||||
|
<section style="position:relative;z-index:10;padding-bottom:3rem;">
|
||||||
|
<div class="container">
|
||||||
|
<partial name="_Notification" />
|
||||||
|
|
||||||
|
<form asp-action="DeleteMultiple" method="post" id="deleteForm">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="nex-card">
|
||||||
|
<div class="top-glow"></div>
|
||||||
|
|
||||||
|
<div class="tbl-head">
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<div class="tbl-title"><i class="fa-solid fa-shield-halved"></i> System Roles</div>
|
||||||
|
<div class="tbl-count">@totalRoles defined</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
<table class="role-table" id="roleTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" class="nex-check" id="selectAll" onclick="toggleAll(this.checked)" /></th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@{
|
||||||
|
var roleColors = new Dictionary<string, string> {
|
||||||
|
{"admin", "#f87171"}, {"user", "#60a5fa"}, {"manager", "#fbbf24"},
|
||||||
|
{"developer", "#c084fc"}, {"viewer", "#34d399"}, {"editor", "#22d3ee"}
|
||||||
|
};
|
||||||
|
var roleIcons = new Dictionary<string, string> {
|
||||||
|
{"admin", "fa-solid fa-crown"}, {"user", "fa-solid fa-user"},
|
||||||
|
{"manager", "fa-solid fa-user-tie"}, {"developer", "fa-solid fa-code"},
|
||||||
|
{"viewer", "fa-solid fa-eye"}, {"editor", "fa-solid fa-pen-nib"}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@foreach (var role in Model)
|
||||||
|
{
|
||||||
|
var key = role.Name?.ToLower() ?? "";
|
||||||
|
var color = roleColors.ContainsKey(key) ? roleColors[key] : "#fb923c";
|
||||||
|
var icon = roleIcons.ContainsKey(key) ? roleIcons[key] : "fa-solid fa-shield-halved";
|
||||||
|
var permCount = role.SelectedPermissions?.Count ?? 0;
|
||||||
|
var permsStr = role.SelectedPermissions != null ? string.Join(",", role.SelectedPermissions) : "";
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" class="nex-check" name="selectedRoles" value="@role.Id" onchange="updateSelectionCount()" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="role-icon" style="background:@(color)20; color:@color"><i class="@icon"></i></div>
|
||||||
|
<span class="role-name">@role.Name</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (permCount == 0)
|
||||||
{
|
{
|
||||||
<tr>
|
<span style="font-size:0.78rem;color:var(--dark-500);font-style:italic"><i class="fa-solid fa-lock"></i> No permissions assigned</span>
|
||||||
<td><input type="checkbox" name="selectedRoles" value="@item.Id" /></td>
|
|
||||||
<td>@item.Name</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-info btn-sm">Edit</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
else if (permCount == totalPerms)
|
||||||
</table>
|
{
|
||||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete the selected roles?');">Delete Selected</button>
|
<span class="perm-pill has"><i class="fa-solid fa-check"></i> All permissions (@permCount)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var perm in role.SelectedPermissions!.Take(4))
|
||||||
|
{
|
||||||
|
<span class="perm-pill has"><i class="fa-solid fa-check"></i> @perm.Split('.').Last()</span>
|
||||||
|
}
|
||||||
|
@if (permCount > 4)
|
||||||
|
{
|
||||||
|
<span class="perm-more" onclick="if(checkPermission(rolePermissions.canEdit)) openEditModal('@role.Id')">+@(permCount - 4) more</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="act-btn edit" title="Edit role"
|
||||||
|
onclick="if(checkPermission(rolePermissions.canEdit)) openEditModal('@role.Id')">
|
||||||
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="act-btn del" title="Delete role"
|
||||||
|
onclick="if(checkPermission(rolePermissions.canDelete)) openDeleteModal('@role.Id', '@Html.Raw(role.Name?.Replace("'", "\\'"))')">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
<h4>No roles defined</h4>
|
||||||
|
<p>Create your first role to start managing access</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="tbl-foot">
|
||||||
|
<div class="selected-info"><span id="selCount">0</span> of @totalRoles selected</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.7rem;color:var(--dark-500);">@DateTime.Now.ToString("MMM dd, yyyy HH:mm")</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ CREATE MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="createModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="m-glow orange"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon orange"><i class="fa-solid fa-plus"></i></div>
|
||||||
|
<div><h3>Create New Role</h3><p>Define a role with specific permissions</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('createModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<form id="createForm" novalidate>
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="m-body">
|
||||||
|
<div id="createErrors" class="modal-errors"><ul></ul></div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-shield-halved"></i> Role Name *</label>
|
||||||
|
<input type="text" name="Name" class="f-input" placeholder="e.g. Developer, Viewer, Manager" required />
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-key"></i> Permissions</label>
|
||||||
|
<div class="perm-groups" id="createPermGroups">
|
||||||
|
@if (permissionGroups != null)
|
||||||
|
{
|
||||||
|
@foreach (var group in permissionGroups)
|
||||||
|
{
|
||||||
|
<div class="perm-group">
|
||||||
|
<div class="perm-group-head">
|
||||||
|
<h5><i class="fa-solid fa-folder"></i> @group.Key</h5>
|
||||||
|
<button type="button" class="perm-group-toggle" onclick="toggleGroupPerms(this, 'create')">Select All</button>
|
||||||
|
</div>
|
||||||
|
<div class="perm-group-body">
|
||||||
|
@foreach (var perm in group.Value)
|
||||||
|
{
|
||||||
|
<div class="perm-chip">
|
||||||
|
<input type="checkbox" name="SelectedPermissions" value="@perm.Value" id="create_@perm.Value.Replace(".", "_")" />
|
||||||
|
<label for="create_@perm.Value.Replace(".", "_")"><i class="@perm.Icon"></i> @perm.DisplayName</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('createModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<button type="submit" class="h-btn pri" id="createSubmitBtn">
|
||||||
|
<div class="btn-spinner" id="createSpinner"></div>
|
||||||
|
<i class="fa-solid fa-plus" id="createIcon"></i> Create Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ EDIT MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="editModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="m-glow blue"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon blue"><i class="fa-solid fa-pen-to-square"></i></div>
|
||||||
|
<div><h3>Edit Role</h3><p>Update role name and permissions</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('editModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<form id="editForm" novalidate>
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="Id" id="editId" />
|
||||||
|
<div class="m-body">
|
||||||
|
<div id="editErrors" class="modal-errors"><ul></ul></div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-shield-halved"></i> Role Name *</label>
|
||||||
|
<input type="text" name="Name" id="editName" class="f-input" required />
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-key"></i> Permissions</label>
|
||||||
|
<div class="perm-groups" id="editPermGroups">
|
||||||
|
@if (permissionGroups != null)
|
||||||
|
{
|
||||||
|
@foreach (var group in permissionGroups)
|
||||||
|
{
|
||||||
|
<div class="perm-group">
|
||||||
|
<div class="perm-group-head">
|
||||||
|
<h5><i class="fa-solid fa-folder"></i> @group.Key</h5>
|
||||||
|
<button type="button" class="perm-group-toggle" onclick="toggleGroupPerms(this, 'edit')">Select All</button>
|
||||||
|
</div>
|
||||||
|
<div class="perm-group-body">
|
||||||
|
@foreach (var perm in group.Value)
|
||||||
|
{
|
||||||
|
<div class="perm-chip">
|
||||||
|
<input type="checkbox" name="SelectedPermissions" value="@perm.Value" id="edit_@perm.Value.Replace(".", "_")" />
|
||||||
|
<label for="edit_@perm.Value.Replace(".", "_")"><i class="@perm.Icon"></i> @perm.DisplayName</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('editModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<button type="submit" class="h-btn pri" id="editSubmitBtn" style="background:linear-gradient(135deg,var(--nex-blue),var(--nex-cyan))">
|
||||||
|
<div class="btn-spinner" id="editSpinner"></div>
|
||||||
|
<i class="fa-solid fa-floppy-disk" id="editIcon"></i> Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ DELETE MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="deleteModal">
|
||||||
|
<div class="modal-box" style="max-width:460px">
|
||||||
|
<div class="m-glow red"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon red"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
||||||
|
<div><h3>Delete Role</h3><p>This action cannot be undone</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('deleteModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="m-body">
|
||||||
|
<p class="del-warning">
|
||||||
|
<i class="fa-solid fa-circle-exclamation"></i> You are about to <strong>permanently delete</strong> the role
|
||||||
|
"<strong id="delRoleName">Role</strong>". All users currently assigned this role will lose its associated permissions.
|
||||||
|
This action is <strong>irreversible</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('deleteModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<form id="singleDeleteForm" asp-action="DeleteMultiple" method="post" style="display:inline">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="selectedRoles" id="delRoleId" />
|
||||||
|
<button type="submit" class="h-btn danger"><i class="fa-solid fa-trash-can"></i> Delete Role</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ BULK DELETE MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="bulkDeleteModal">
|
||||||
|
<div class="modal-box" style="max-width:460px">
|
||||||
|
<div class="m-glow red"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon red"><i class="fa-solid fa-shield-halved"></i></div>
|
||||||
|
<div><h3>Delete Selected Roles</h3><p>Bulk removal confirmation</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('bulkDeleteModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="m-body">
|
||||||
|
<p class="del-warning">
|
||||||
|
<i class="fa-solid fa-circle-exclamation"></i> You are about to <strong>permanently delete <span id="bulkCount">0</span> roles</strong>.
|
||||||
|
All users assigned these roles will lose their associated permissions. This is <strong>irreversible</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('bulkDeleteModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<button type="button" class="h-btn danger" onclick="submitBulkDelete()"><i class="fa-solid fa-trash-can"></i> Delete All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function selectAll(source) {
|
const rolePermissions = {
|
||||||
checkboxes = document.querySelectorAll('input[name="selectedRoles"]');
|
canCreate: @User.HasPermission(Permissions.Roles.Create).ToString().ToLower(),
|
||||||
for (var i = 0; i < checkboxes.length; i++) {
|
canEdit: @User.HasPermission(Permissions.Roles.Edit).ToString().ToLower(),
|
||||||
checkboxes[i].checked = source.checked;
|
canDelete: @User.HasPermission(Permissions.Roles.Delete).ToString().ToLower()
|
||||||
}
|
};
|
||||||
|
const accessDeniedUrl = '@Url.Action("Index", "AccessDenied", new { area = "Admin" })';
|
||||||
|
|
||||||
|
function checkPermission(permission) {
|
||||||
|
if (!permission) {
|
||||||
|
window.location.href = accessDeniedUrl;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
</script>
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'nex-toast ' + type;
|
||||||
|
const icon = type === 'success' ? 'fa-circle-check' : 'fa-circle-xmark';
|
||||||
|
toast.innerHTML = '<i class="fa-solid ' + icon + '"></i> ' + message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
function toggleAll(checked) {
|
||||||
|
document.querySelectorAll('input[name="selectedRoles"]').forEach(cb => cb.checked = checked);
|
||||||
|
updateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionCount() {
|
||||||
|
const checked = document.querySelectorAll('input[name="selectedRoles"]:checked').length;
|
||||||
|
document.getElementById('selCount').textContent = checked;
|
||||||
|
const bulkBtn = document.getElementById('bulkDeleteBtn');
|
||||||
|
if (checked > 0) bulkBtn.classList.remove('d-none');
|
||||||
|
else bulkBtn.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal helpers
|
||||||
|
function openModal(id) { document.getElementById(id).classList.add('active'); document.body.style.overflow = 'hidden'; }
|
||||||
|
function closeModal(id) { document.getElementById(id).classList.remove('active'); document.body.style.overflow = ''; }
|
||||||
|
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(o => {
|
||||||
|
o.addEventListener('click', e => { if (e.target === o) closeModal(o.id); });
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.active').forEach(m => closeModal(m.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
function showModalErrors(containerId, errors) {
|
||||||
|
const c = document.getElementById(containerId);
|
||||||
|
const ul = c.querySelector('ul');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
errors.forEach(err => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = '<i class="fa-solid fa-circle-xmark"></i> ' + err;
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
c.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideModalErrors(id) { document.getElementById(id).style.display = 'none'; }
|
||||||
|
|
||||||
|
function setLoading(btnId, spinnerId, iconId, loading) {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const spinner = document.getElementById(spinnerId);
|
||||||
|
const icon = document.getElementById(iconId);
|
||||||
|
btn.disabled = loading;
|
||||||
|
spinner.style.display = loading ? 'block' : 'none';
|
||||||
|
if (icon) icon.style.display = loading ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle group permissions
|
||||||
|
function toggleGroupPerms(btn, prefix) {
|
||||||
|
const body = btn.closest('.perm-group').querySelector('.perm-group-body');
|
||||||
|
const checkboxes = body.querySelectorAll('input[type="checkbox"]');
|
||||||
|
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
||||||
|
|
||||||
|
checkboxes.forEach(cb => cb.checked = !allChecked);
|
||||||
|
btn.textContent = allChecked ? 'Select All' : 'Deselect All';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CREATE ──
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('createForm').reset();
|
||||||
|
hideModalErrors('createErrors');
|
||||||
|
openModal('createModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('createForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideModalErrors('createErrors');
|
||||||
|
|
||||||
|
const name = this.querySelector('[name="Name"]').value.trim();
|
||||||
|
if (!name) { showModalErrors('createErrors', ['Role name is required.']); return; }
|
||||||
|
|
||||||
|
setLoading('createSubmitBtn', 'createSpinner', 'createIcon', true);
|
||||||
|
|
||||||
|
fetch('@Url.Action("CreateAjax")', { method: 'POST', body: new FormData(this) })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setLoading('createSubmitBtn', 'createSpinner', 'createIcon', false);
|
||||||
|
if (data.success) {
|
||||||
|
closeModal('createModal');
|
||||||
|
showToast(data.message, 'success');
|
||||||
|
setTimeout(() => location.reload(), 800);
|
||||||
|
} else {
|
||||||
|
showModalErrors('createErrors', data.errors || ['An error occurred.']);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading('createSubmitBtn', 'createSpinner', 'createIcon', false);
|
||||||
|
showModalErrors('createErrors', ['Network error. Please try again.']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── EDIT ──
|
||||||
|
function openEditModal(roleId) {
|
||||||
|
hideModalErrors('editErrors');
|
||||||
|
document.querySelectorAll('#editPermGroups input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||||
|
|
||||||
|
// Fetch role data with permissions
|
||||||
|
fetch('@Url.Action("GetRolePermissions")' + '?id=' + roleId)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('editId').value = data.id;
|
||||||
|
document.getElementById('editName').value = data.name;
|
||||||
|
|
||||||
|
// Check matching permissions
|
||||||
|
if (data.permissions) {
|
||||||
|
data.permissions.forEach(p => {
|
||||||
|
const cb = document.getElementById('edit_' + p.replace(/\./g, '_'));
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update group toggle buttons
|
||||||
|
document.querySelectorAll('#editPermGroups .perm-group').forEach(group => {
|
||||||
|
const cbs = group.querySelectorAll('.perm-group-body input[type="checkbox"]');
|
||||||
|
const btn = group.querySelector('.perm-group-toggle');
|
||||||
|
const allChecked = Array.from(cbs).every(cb => cb.checked);
|
||||||
|
btn.textContent = allChecked ? 'Deselect All' : 'Select All';
|
||||||
|
});
|
||||||
|
|
||||||
|
openModal('editModal');
|
||||||
|
} else {
|
||||||
|
showToast('Failed to load role data.', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => showToast('Network error.', 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideModalErrors('editErrors');
|
||||||
|
|
||||||
|
const name = this.querySelector('[name="Name"]').value.trim();
|
||||||
|
if (!name) { showModalErrors('editErrors', ['Role name is required.']); return; }
|
||||||
|
|
||||||
|
setLoading('editSubmitBtn', 'editSpinner', 'editIcon', true);
|
||||||
|
|
||||||
|
fetch('@Url.Action("EditAjax")', { method: 'POST', body: new FormData(this) })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setLoading('editSubmitBtn', 'editSpinner', 'editIcon', false);
|
||||||
|
if (data.success) {
|
||||||
|
closeModal('editModal');
|
||||||
|
showToast(data.message, 'success');
|
||||||
|
setTimeout(() => location.reload(), 800);
|
||||||
|
} else {
|
||||||
|
showModalErrors('editErrors', data.errors || ['An error occurred.']);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading('editSubmitBtn', 'editSpinner', 'editIcon', false);
|
||||||
|
showModalErrors('editErrors', ['Network error. Please try again.']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE ──
|
||||||
|
function openDeleteModal(roleId, roleName) {
|
||||||
|
document.getElementById('delRoleId').value = roleId;
|
||||||
|
document.getElementById('delRoleName').textContent = roleName;
|
||||||
|
openModal('deleteModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmBulkDelete() {
|
||||||
|
const count = document.querySelectorAll('input[name="selectedRoles"]:checked').length;
|
||||||
|
if (count === 0) return;
|
||||||
|
document.getElementById('bulkCount').textContent = count;
|
||||||
|
openModal('bulkDeleteModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBulkDelete() {
|
||||||
|
closeModal('bulkDeleteModal');
|
||||||
|
document.getElementById('deleteForm').submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
Web/Areas/Admin/Views/Shared/_AccessDeniedModal.cshtml
Normal file
77
Web/Areas/Admin/Views/Shared/_AccessDeniedModal.cshtml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<!-- Access Denied Modal — shared across all admin pages -->
|
||||||
|
<div class="modal-overlay" id="accessDeniedModal" style="display:none;position:fixed;inset:0;z-index:99999;background:rgba(2,6,23,0.8);backdrop-filter:blur(8px);align-items:center;justify-content:center;padding:2rem;">
|
||||||
|
<div style="background:#1e293b;border:1px solid rgba(255,255,255,0.08);border-radius:20px;width:100%;max-width:460px;position:relative;animation:adSlideUp .3s ease;overflow:hidden;">
|
||||||
|
<div style="position:absolute;top:0;left:0;right:0;height:3px;border-radius:20px 20px 0 0;background:linear-gradient(90deg,#f87171,#dc2626);"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:1.5rem 1.75rem 1rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.85rem;">
|
||||||
|
<div style="width:42px;height:42px;border-radius:12px;background:rgba(248,113,113,0.15);display:flex;align-items:center;justify-content:center;font-size:1.1rem;color:#f87171;">
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size:1.1rem;font-weight:700;color:#f8fafc;margin:0;">Access Denied</h3>
|
||||||
|
<p style="font-size:0.78rem;color:#94a3b8;margin:2px 0 0;">Insufficient permissions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="closeAccessDeniedModal()" style="width:36px;height:36px;border-radius:10px;border:1px solid rgba(255,255,255,0.08);background:none;color:#94a3b8;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:0.9rem;">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;padding:1rem 1.75rem 2rem;">
|
||||||
|
<div style="width:70px;height:70px;border-radius:16px;background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.2);display:flex;align-items:center;justify-content:center;margin:0 auto 1.25rem;font-size:1.8rem;color:#f87171;animation:adShake .6s ease-in-out;">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
<h4 style="font-size:1.15rem;font-weight:700;color:#f8fafc;margin:0 0 0.5rem;">You Don't Have Permission</h4>
|
||||||
|
<p style="font-size:0.85rem;color:#94a3b8;line-height:1.7;margin:0 0 1.5rem;">
|
||||||
|
Your current role does not include the required privileges for this action.
|
||||||
|
Please contact your <span style="color:#f87171;font-weight:600;">system administrator</span> to request access.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;background:rgba(248,113,113,0.08);border:1px solid rgba(248,113,113,0.15);border-radius:10px;margin:0 0 1.5rem;text-align:left;">
|
||||||
|
<i class="fa-solid fa-circle-info" style="color:#f87171;flex-shrink:0;"></i>
|
||||||
|
<span style="font-size:0.78rem;color:#cbd5e1;line-height:1.5;">
|
||||||
|
Logged in as: <strong style="color:#f8fafc;">@User.Identity?.Name</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="closeAccessDeniedModal()" style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.65rem 1.5rem;border-radius:10px;font-family:'Space Grotesk',sans-serif;font-size:0.85rem;font-weight:600;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.05);color:#e2e8f0;cursor:pointer;">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i> Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@@keyframes adSlideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
@@keyframes adShake { 0%,100%{transform:translateX(0)} 15%{transform:translateX(-8px)} 30%{transform:translateX(8px)} 45%{transform:translateX(-6px)} 60%{transform:translateX(6px)} 75%{transform:translateX(-3px)} 90%{transform:translateX(3px)} }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openAccessDeniedModal() {
|
||||||
|
var m = document.getElementById('accessDeniedModal');
|
||||||
|
m.style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function closeAccessDeniedModal() {
|
||||||
|
var m = document.getElementById('accessDeniedModal');
|
||||||
|
m.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
document.getElementById('accessDeniedModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeAccessDeniedModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('accessDeniedModal').style.display === 'flex') {
|
||||||
|
closeAccessDeniedModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Auto-show if redirected with ?accessDenied=true
|
||||||
|
(function() {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('accessDenied') === 'true') {
|
||||||
|
openAccessDeniedModal();
|
||||||
|
// Clean URL without reloading
|
||||||
|
var cleanUrl = window.location.pathname + window.location.search.replace(/[?&]accessDenied=true/, '').replace(/^\?$/, '');
|
||||||
|
window.history.replaceState({}, '', cleanUrl);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
@ -3,592 +3,289 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>@ViewData["Title"] - Web</title>
|
<title>@ViewData["Title"] - NVKN</title>
|
||||||
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
|
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
|
||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
<link rel="stylesheet" href="~/Web.styles.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/Web.styles.css" asp-append-version="true" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/css/toastr.min.css" />
|
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/css/toastr.min.css" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Override default styles while preserving functionality */
|
:root{
|
||||||
|
--bg-deep:#060a13;--bg-card:#182036;
|
||||||
|
--text-primary:#e8edf4;--text-secondary:#94a3b8;--text-muted:#5e6e82;--text-faint:#3d4e63;
|
||||||
/* Modern Glassmorphism Sidebar - Icons only by default */
|
--accent-blue:#3b82f6;--accent-teal:#14b8a6;--accent-orange:#f97316;
|
||||||
#sidebar {
|
--accent-red:#ef4444;--accent-green:#10b981;
|
||||||
position: sticky !important;
|
--glass-border:rgba(255,255,255,0.06);--glass-hover:rgba(255,255,255,0.12);
|
||||||
top: 0 !important;
|
--nv-w:72px;--nv-exp:260px;--nv-top:60px;
|
||||||
height: 100vh !important;
|
--ease:cubic-bezier(0.4,0,0.2,1);
|
||||||
min-width: 80px; /* Small width by default - icons only */
|
|
||||||
max-width: 80px;
|
|
||||||
background: rgba(10, 15, 25, 0.85) !important;
|
|
||||||
backdrop-filter: blur(25px) saturate(1.8) !important;
|
|
||||||
border-right: 1px solid rgba(59, 130, 246, 0.15) !important;
|
|
||||||
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.4), 0 0 60px rgba(59, 130, 246, 0.05), inset -1px 0 0 rgba(255, 255, 255, 0.05) !important;
|
|
||||||
color: #fff;
|
|
||||||
-webkit-transition: all 0.3s;
|
|
||||||
-o-transition: all 0.3s;
|
|
||||||
transition: all 0.3s;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
z-index: 1000;
|
|
||||||
text-align: center; /* Center icons by default */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar::before {
|
/* Kill site.css originals */
|
||||||
content: '';
|
body{font-family:'Inter',-apple-system,sans-serif;background:none;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||||
position: absolute;
|
#sidebar{display:none !important}
|
||||||
top: 0;
|
#content{display:none !important}
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, transparent 30%, rgba(20, 184, 166, 0.05) 70%, rgba(249, 115, 22, 0.03) 100%);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Default: Hide text, show only icons centered */
|
/* ===== SIDEBAR ===== */
|
||||||
#sidebar ul.components li a {
|
#nvkn-sidebar{
|
||||||
padding: 15px 0;
|
position:fixed;top:0;left:0;height:100vh;width:var(--nv-w);
|
||||||
display: flex;
|
background:rgba(8,12,24,0.94);backdrop-filter:blur(30px) saturate(1.6);
|
||||||
flex-direction: column;
|
border-right:1px solid var(--glass-border);z-index:1100;
|
||||||
align-items: center;
|
display:flex;flex-direction:column;transition:width 0.3s var(--ease);overflow:hidden;
|
||||||
justify-content: center;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
margin: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar ul.components li a span.bi {
|
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide text by default */
|
|
||||||
#sidebar ul.components li a .nav-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expanded state: Show both icons and text */
|
|
||||||
#sidebar.active {
|
|
||||||
min-width: 280px; /* Wide when expanded */
|
|
||||||
max-width: 280px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar.active ul.components li a {
|
|
||||||
padding: 14px 18px;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin: 4px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar.active ul.components li a span.bi {
|
|
||||||
margin-right: 15px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar.active ul.components li a .nav-text {
|
|
||||||
display: inline; /* Show text when expanded */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logo styling */
|
|
||||||
#sidebar .logo {
|
|
||||||
display: block;
|
|
||||||
color: #fff !important;
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 1.2rem !important; /* Smaller by default */
|
|
||||||
padding: 20px 0;
|
|
||||||
-webkit-transition: 0.3s;
|
|
||||||
-o-transition: 0.3s;
|
|
||||||
transition: 0.3s;
|
|
||||||
text-decoration: none;
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #14b8a6 50%, #f97316 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-bottom: 1px solid rgba(59, 130, 246, 0.15);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar.active .logo {
|
|
||||||
font-size: 1.8rem !important; /* Larger when expanded */
|
|
||||||
padding: 20px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation List Styling */
|
|
||||||
#sidebar ul.components {
|
|
||||||
padding: 0;
|
|
||||||
-webkit-transition: 0.3s;
|
|
||||||
-o-transition: 0.3s;
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar ul li {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar ul li > ul {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar ul li > ul li {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar ul li a:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: rgba(59, 130, 246, 0.12) !important;
|
|
||||||
transform: translateX(4px);
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar ul li.active > a {
|
|
||||||
background: rgba(20, 184, 166, 0.15) !important;
|
|
||||||
color: #fff;
|
|
||||||
border: 1px solid rgba(20, 184, 166, 0.3);
|
|
||||||
box-shadow: 0 2px 8px rgba(20, 184, 166, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive behavior */
|
|
||||||
@@media (max-width: 991.98px) {
|
|
||||||
#sidebar {
|
|
||||||
position: fixed !important;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 1001;
|
|
||||||
left: -80px !important; /* Hide by default on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar.active {
|
|
||||||
left: 0 !important; /* Show when active on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebarCollapse span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
#nvkn-sidebar::before{content:'';position:absolute;inset:0;background:linear-gradient(180deg,rgba(59,130,246,0.04),transparent 40%,rgba(20,184,166,0.03));pointer-events:none}
|
||||||
|
#nvkn-sidebar.expanded{width:var(--nv-exp)}
|
||||||
|
|
||||||
/* Content Area */
|
.nv-logo{display:flex;align-items:center;justify-content:center;height:var(--nv-top);padding:0 16px;border-bottom:1px solid var(--glass-border);flex-shrink:0;position:relative;z-index:1}
|
||||||
.wrapper {
|
.nv-logo-mark{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--accent-blue),var(--accent-teal));display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 4px 16px rgba(59,130,246,0.25)}
|
||||||
width: 100%;
|
.nv-logo-text{font-family:'JetBrains Mono',monospace;font-weight:800;font-size:18px;letter-spacing:.08em;background:linear-gradient(135deg,var(--accent-blue),var(--accent-teal),var(--accent-orange));-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-left:14px;white-space:nowrap;display:none}
|
||||||
|
#nvkn-sidebar.expanded .nv-logo-text{display:inline}
|
||||||
|
|
||||||
|
.nv-nav{flex:1;padding:12px 0;overflow-y:auto;overflow-x:hidden;position:relative;z-index:1}
|
||||||
|
.nv-nav::-webkit-scrollbar{width:3px}
|
||||||
|
.nv-nav::-webkit-scrollbar-thumb{background:rgba(59,130,246,0.2);border-radius:3px}
|
||||||
|
.nv-nav ul{list-style:none;padding:0;margin:0}
|
||||||
|
|
||||||
|
.nv-link{display:flex;align-items:center;justify-content:center;height:44px;margin:2px 8px;border-radius:10px;color:var(--text-secondary);text-decoration:none;transition:all .25s var(--ease);position:relative;border:1px solid transparent}
|
||||||
|
#nvkn-sidebar.expanded .nv-link{justify-content:flex-start;padding:0 16px;margin:2px 10px}
|
||||||
|
.nv-link:hover{color:#fff;background:rgba(59,130,246,0.1);border-color:rgba(59,130,246,0.15);text-decoration:none}
|
||||||
|
.nv-link:hover .nv-icon{color:var(--accent-blue)}
|
||||||
|
.nv-link.active{background:rgba(20,184,166,0.1);border-color:rgba(20,184,166,0.2);color:#fff}
|
||||||
|
.nv-link.active .nv-icon{color:var(--accent-teal)}
|
||||||
|
.nv-link.active::after{content:'';position:absolute;left:0;top:8px;bottom:8px;width:3px;background:var(--accent-teal);border-radius:0 3px 3px 0}
|
||||||
|
|
||||||
|
.nv-icon{font-size:18px;width:24px;text-align:center;flex-shrink:0;transition:color .2s}
|
||||||
|
.nv-text{font-size:13px;font-weight:500;white-space:nowrap;margin-left:14px;opacity:0;width:0;overflow:hidden;transition:opacity .15s,width .25s var(--ease)}
|
||||||
|
#nvkn-sidebar.expanded .nv-text{opacity:1;width:auto}
|
||||||
|
|
||||||
|
.nv-tip{position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%) scale(.9);background:var(--bg-card);color:var(--text-primary);padding:6px 12px;border-radius:8px;font-size:12px;font-weight:500;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s,transform .15s;border:1px solid var(--glass-border);box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:9999}
|
||||||
|
.nv-link:hover .nv-tip{opacity:1;transform:translateY(-50%) scale(1)}
|
||||||
|
#nvkn-sidebar.expanded .nv-tip{display:none}
|
||||||
|
|
||||||
|
.nv-footer{padding:12px;border-top:1px solid var(--glass-border);flex-shrink:0;position:relative;z-index:1;font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text-faint);text-align:center;letter-spacing:.05em}
|
||||||
|
#nvkn-sidebar.expanded .nv-footer{text-align:left;padding-left:20px}
|
||||||
|
|
||||||
|
/* ===== CONTENT WRAPPER ===== */
|
||||||
|
.nv-content{margin-left:var(--nv-w);min-height:100vh;display:flex;flex-direction:column;transition:margin-left .3s var(--ease)}
|
||||||
|
#nvkn-sidebar.expanded ~ .nv-overlay ~ .nv-content,
|
||||||
|
#nvkn-sidebar.expanded ~ .nv-content{margin-left:var(--nv-exp)}
|
||||||
|
|
||||||
|
/* ===== TOPBAR ===== */
|
||||||
|
.nv-topbar{height:var(--nv-top);background:rgba(8,12,24,0.92);backdrop-filter:blur(30px) saturate(1.6);border-bottom:1px solid var(--glass-border);display:flex;align-items:center;padding:0 24px;gap:16px;position:sticky;top:0;z-index:1050;flex-shrink:0}
|
||||||
|
.nv-topbar::before{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(59,130,246,0.03),transparent,rgba(20,184,166,0.02));pointer-events:none}
|
||||||
|
|
||||||
|
#nvkn-toggle{width:40px;height:40px;border-radius:10px;background:rgba(59,130,246,0.15);border:1px solid rgba(59,130,246,0.3);color:rgba(255,255,255,0.9);display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;transition:all .25s var(--ease);flex-shrink:0;position:relative;z-index:10;outline:none;box-shadow:0 2px 8px rgba(59,130,246,0.2)}
|
||||||
|
#nvkn-toggle:hover{background:rgba(59,130,246,0.25);border-color:rgba(59,130,246,0.5);color:#fff;transform:scale(1.05);box-shadow:0 4px 16px rgba(59,130,246,0.3)}
|
||||||
|
|
||||||
|
.nv-ctx{display:flex;align-items:center;gap:10px;flex:1;position:relative;z-index:1}
|
||||||
|
.nv-divider{width:1px;height:24px;background:var(--glass-border)}
|
||||||
|
.nv-crumbs{display:flex;align-items:center;gap:8px;font-size:13px}
|
||||||
|
.nv-crumbs .c-m{color:var(--text-muted);font-weight:500}
|
||||||
|
.nv-crumbs .c-a{color:var(--text-primary);font-weight:600}
|
||||||
|
.nv-crumbs .c-s{color:var(--text-faint);font-size:10px}
|
||||||
|
|
||||||
|
.nv-actions{display:flex;align-items:center;gap:8px;position:relative;z-index:1001}
|
||||||
|
|
||||||
|
.nv-bell{width:38px;height:38px;border-radius:10px;background:rgba(255,255,255,0.03);border:1px solid var(--glass-border);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;font-size:16px;cursor:pointer;transition:all .25s var(--ease);position:relative}
|
||||||
|
.nv-bell:hover{background:rgba(255,255,255,0.06);border-color:var(--glass-hover);color:var(--text-primary)}
|
||||||
|
.nv-badge{position:absolute;top:-2px;right:-2px;min-width:18px;height:18px;border-radius:9px;background:linear-gradient(135deg,var(--accent-red),#dc2626);color:#fff;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid var(--bg-deep);box-shadow:0 2px 8px rgba(239,68,68,0.4)}
|
||||||
|
|
||||||
|
.nv-acct{display:flex;align-items:center;gap:10px;padding:6px 14px 6px 6px;border-radius:10px;background:rgba(20,184,166,0.06);border:1px solid rgba(20,184,166,0.15);color:var(--text-secondary);cursor:pointer;transition:all .25s var(--ease);font-size:13px;font-weight:500;font-family:inherit}
|
||||||
|
.nv-acct:hover{background:rgba(20,184,166,0.12);border-color:rgba(20,184,166,0.3);color:var(--text-primary)}
|
||||||
|
.nv-avatar{width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,var(--accent-teal),var(--accent-blue));display:flex;align-items:center;justify-content:center;font-size:12px;color:#fff;font-weight:700}
|
||||||
|
|
||||||
|
/* ===== DROPDOWNS ===== */
|
||||||
|
.nv-actions .dropdown{position:relative;z-index:1001}
|
||||||
|
.nv-actions .dropdown-menu{background:rgba(12,16,30,0.97) !important;backdrop-filter:blur(32px) saturate(1.8) !important;border:1px solid rgba(59,130,246,0.18) !important;border-radius:14px !important;box-shadow:0 20px 60px rgba(0,0,0,0.6),0 0 80px rgba(59,130,246,0.08) !important;padding:10px !important;margin-top:8px !important;min-width:220px !important;z-index:9999 !important;}
|
||||||
|
@@keyframes nvdd{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
.nv-actions .dropdown-header{color:var(--text-muted) !important;font-family:'JetBrains Mono',monospace;font-size:10px !important;font-weight:600 !important;text-transform:uppercase !important;letter-spacing:.8px !important;padding:8px 14px 6px !important}
|
||||||
|
.nv-actions .dropdown-divider{border-color:rgba(255,255,255,0.06) !important;margin:6px 0 !important}
|
||||||
|
.nv-actions .dropdown-item{color:var(--text-secondary) !important;font-size:13px;font-weight:500;padding:10px 14px;border-radius:8px;transition:all .25s var(--ease);display:flex;align-items:center;gap:10px;margin:2px 0;border:1px solid transparent}
|
||||||
|
.nv-actions .dropdown-item:hover{background:rgba(59,130,246,0.12) !important;color:#fff !important;border-color:rgba(59,130,246,0.15)}
|
||||||
|
.nv-actions .dropdown-item.text-danger{color:rgba(239,68,68,0.85) !important}
|
||||||
|
.nv-actions .dropdown-item.text-danger:hover{background:rgba(239,68,68,0.12) !important;color:#fff !important}
|
||||||
|
|
||||||
|
.nv-notif-dd{min-width:340px !important;max-height:440px;overflow-y:auto}
|
||||||
|
.nv-notif-wrap{padding:4px 6px;list-style:none}
|
||||||
|
.nv-notif-wrap:hover{background:none !important}
|
||||||
|
|
||||||
|
#notifications{display:flex;flex-direction:column;gap:6px}
|
||||||
|
#notifications .list-group-item{display:flex;align-items:center;gap:12px;background:rgba(20,184,166,0.06);border:1px solid rgba(20,184,166,0.12);border-radius:10px;padding:12px 14px;transition:all .25s var(--ease);cursor:pointer;color:transparent;font-size:0}
|
||||||
|
#notifications .list-group-item::before{content:'\F4C3';font-family:'bootstrap-icons';color:var(--accent-teal);font-size:14px;flex-shrink:0;width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:rgba(20,184,166,0.1);border-radius:8px;border:1px solid rgba(20,184,166,0.2)}
|
||||||
|
#notifications .list-group-item:hover{background:rgba(20,184,166,0.14);border-color:rgba(20,184,166,0.28);transform:translateX(3px)}
|
||||||
|
#notifications .list-group-item a{color:var(--text-primary);text-decoration:none;font-size:13px;font-weight:500;line-height:1.4;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#notifications .list-group-item a:hover{color:var(--accent-teal)}
|
||||||
|
|
||||||
|
.nv-overlay{display:none;position:fixed;background:rgba(0,0,0,0.5);z-index:1050;backdrop-filter:blur(4px)}
|
||||||
|
#nvkn-sidebar.expanded ~ .nv-overlay{display:block}
|
||||||
|
|
||||||
|
main{flex:1}
|
||||||
|
|
||||||
|
@@media(max-width:991.98px){
|
||||||
|
#nvkn-sidebar{left:-280px;width:260px}
|
||||||
|
#nvkn-sidebar.expanded{left:0;width:260px}
|
||||||
|
.nv-content{margin-left:0 !important}
|
||||||
|
#nvkn-sidebar.expanded ~ .nv-overlay ~ .nv-content,#nvkn-sidebar.expanded ~ .nv-content{margin-left:0 !important}
|
||||||
|
.nv-link{justify-content:flex-start;padding:0 16px}
|
||||||
|
.nv-text{opacity:1;width:auto}
|
||||||
|
.nv-tip{display:none}
|
||||||
|
.nv-logo-text{display:inline}
|
||||||
}
|
}
|
||||||
|
@@media(max-width:768px){
|
||||||
#content {
|
.nv-topbar{padding:0 16px}
|
||||||
width: 100%;
|
.nv-crumbs,.nv-divider{display:none}
|
||||||
padding: 0;
|
.nv-acct-label{display:none}
|
||||||
min-height: 100vh;
|
|
||||||
-webkit-transition: all 0.3s;
|
|
||||||
-o-transition: all 0.3s;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern Navbar Styling */
|
|
||||||
.navbar {
|
|
||||||
padding: 15px 10px;
|
|
||||||
background: rgba(5, 8, 18, 0.85) !important;
|
|
||||||
backdrop-filter: blur(25px) saturate(1.8);
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
border-bottom: 1px solid rgba(59, 130, 246, 0.15);
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 60px rgba(59, 130, 246, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, transparent 30%, rgba(20, 184, 166, 0.05) 70%, rgba(249, 115, 22, 0.03) 100%);
|
|
||||||
border-radius: inherit;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern Button Styling */
|
|
||||||
.btn.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #3b82f6, #14b8a6) !important;
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.btn-primary:hover,
|
|
||||||
.btn.btn-primary:focus {
|
|
||||||
background: linear-gradient(135deg, #2563eb, #0d9488) !important;
|
|
||||||
border-color: rgba(59, 130, 246, 0.5) !important;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: linear-gradient(135deg, #ef4444, #dc2626) !important;
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: linear-gradient(135deg, #dc2626, #b91c1c) !important;
|
|
||||||
border-color: rgba(239, 68, 68, 0.5) !important;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown Styling */
|
|
||||||
.dropdown-menu {
|
|
||||||
background: rgba(5, 8, 18, 0.95) !important;
|
|
||||||
backdrop-filter: blur(25px) saturate(1.8) !important;
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.2) !important;
|
|
||||||
border-radius: 12px !important;
|
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 60px rgba(59, 130, 246, 0.1) !important;
|
|
||||||
padding: 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
color: rgba(255, 255, 255, 0.85) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background: rgba(59, 130, 246, 0.15) !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge Styling */
|
|
||||||
.badge-danger {
|
|
||||||
background: linear-gradient(135deg, #ef4444, #dc2626) !important;
|
|
||||||
color: white !important;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text colors */
|
|
||||||
.text-white {
|
|
||||||
color: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
#sidebar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(59, 130, 246, 0.3);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sidebar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(59, 130, 246, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Preserve original footer styling */
|
|
||||||
.footer {
|
|
||||||
padding: 0 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@media (max-width: 991.98px) {
|
|
||||||
.footer {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Preserve original dropdown toggle */
|
|
||||||
a[data-toggle="collapse"] {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 0;
|
|
||||||
-webkit-transform: translateY(-50%);
|
|
||||||
-ms-transform: translateY(-50%);
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar button styling */
|
|
||||||
.navbar-btn {
|
|
||||||
-webkit-box-shadow: none;
|
|
||||||
box-shadow: none;
|
|
||||||
outline: none !important;
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="wrapper d-flex align-items-stretch">
|
<div class="wrapper align-items-stretch">
|
||||||
<nav id="sidebar">
|
<!-- Sidebar (new ID avoids site.css conflicts) -->
|
||||||
<h1><a class="logo">NVKN</a></h1>
|
<nav id="nvkn-sidebar">
|
||||||
<ul class="list-unstyled components mb-5">
|
<div class="nv-logo">
|
||||||
<li class="sidebarCollapse">
|
|
||||||
<a asp-controller="admin" asp-action="index">
|
<span class="nv-logo-text">NVKN</span>
|
||||||
<span class="bi bi-speedometer"></span>
|
</div>
|
||||||
<span class="nav-text">Admin</span>
|
<div class="nv-nav">
|
||||||
</a>
|
<ul>
|
||||||
</li>
|
<li><a asp-controller="Admin" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-speedometer2"></span><span class="nv-text">Admin</span><span class="nv-tip">Admin</span></a></li>
|
||||||
<li>
|
<li><a asp-controller="Page" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-file-earmark-fill"></span><span class="nv-text">Pages</span><span class="nv-tip">Pages</span></a></li>
|
||||||
<a asp-controller="Page" asp-action="index">
|
<li><a asp-controller="Banner" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-card-image"></span><span class="nv-text">Banners</span><span class="nv-tip">Banners</span></a></li>
|
||||||
<span class="bi bi-file-earmark-fill"></span>
|
<li><a asp-controller="Footer" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-c-circle-fill"></span><span class="nv-text">Footer</span><span class="nv-tip">Footer</span></a></li>
|
||||||
<span class="nav-text">Pages</span>
|
<li><a asp-controller="Address" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-pin-map"></span><span class="nv-text">Address</span><span class="nv-tip">Address</span></a></li>
|
||||||
</a>
|
<li><a asp-controller="SocialMedia" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-collection-play-fill"></span><span class="nv-text">Social Media</span><span class="nv-tip">Social Media</span></a></li>
|
||||||
</li>
|
<li><a asp-controller="Questionnaire" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-question-circle"></span><span class="nv-text">Survey</span><span class="nv-tip">Survey</span></a></li>
|
||||||
<li>
|
<li><a asp-controller="SurveyAnalysis" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-graph-up-arrow"></span><span class="nv-text">Analyzer</span><span class="nv-tip">Analyzer</span></a></li>
|
||||||
<a asp-controller="banner" asp-action="index">
|
<li><a asp-controller="UserResponse" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-clipboard-data"></span><span class="nv-text">Response</span><span class="nv-tip">Response</span></a></li>
|
||||||
<span class="bi bi-card-image"></span>
|
<li><a asp-controller="UserResponseStatus" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-heart-pulse"></span><span class="nv-text">User status</span><span class="nv-tip">User status</span></a></li>
|
||||||
<span class="nav-text">Banners</span>
|
<li><a asp-controller="Newsletters" asp-action="Index" class="nv-link"><span class="nv-icon bi bi-newspaper"></span><span class="nv-text">Subscribers</span><span class="nv-tip">Subscribers</span></a></li>
|
||||||
</a>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
<li>
|
<div class="nv-footer">v1.0</div>
|
||||||
<a asp-controller="footer" asp-action="index">
|
|
||||||
<span class="bi bi-c-circle-fill"></span>
|
|
||||||
<span class="nav-text">Footer</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a asp-controller="address" asp-action="index">
|
|
||||||
<span class="bi bi-pin-map"></span>
|
|
||||||
<span class="nav-text">Address</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a asp-controller="SocialMedia" asp-action="index">
|
|
||||||
<span class="bi bi-collection-play-fill"></span>
|
|
||||||
<span class="nav-text">Social Media</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a asp-controller="Questionnaire" asp-action="index">
|
|
||||||
<span class="bi bi-question-circle"></span>
|
|
||||||
<span class="nav-text">Survey</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a asp-controller="SurveyAnalysis" asp-action="index">
|
|
||||||
<span class="bi bi-graph-up-arrow"></span>
|
|
||||||
<span class="nav-text">Analyzer</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a asp-controller="UserResponse" asp-action="index">
|
|
||||||
<span class="bi bi-clipboard-data"></span>
|
|
||||||
<span class="nav-text">Response</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a asp-controller="UserResponseStatus" asp-action="index">
|
|
||||||
<span class="bi bi-heart-pulse"></span>
|
|
||||||
<span class="nav-text">User status</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a asp-controller="newsletters" asp-action="index">
|
|
||||||
<span class="bi bi-newspaper"></span>
|
|
||||||
<span class="nav-text">Subscibers</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<div class="nv-overlay" id="nvkn-overlay"></div>
|
||||||
<div id="content">
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-brimary">
|
<!-- Content -->
|
||||||
<div class="container-fluid">
|
<div class="nv-content">
|
||||||
<button type="button" id="sidebarCollapse" class="btn btn-primary">
|
<div class="nv-topbar">
|
||||||
<i class="bi bi-list"></i>
|
<button type="button" id="nvkn-toggle"><i class="bi bi-list"></i></button>
|
||||||
<span class="sr-only">Toggle Menu</span>
|
<div class="nv-ctx">
|
||||||
</button>
|
<div class="nv-divider"></div>
|
||||||
<button class="navbar-toggler btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
<div class="nv-crumbs">
|
||||||
aria-expanded="false" aria-label="Toggle navigation">
|
<span class="c-m">Admin</span>
|
||||||
<i class="bi bi-list"></i>
|
<span class="c-s"><i class="bi bi-chevron-right"></i></span>
|
||||||
<span class="sr-only"></span>
|
<span class="c-a">@ViewData["Title"]</span>
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="nv-actions">
|
||||||
<ul class="nav navbar-nav ml-auto">
|
<span class="dropdown">
|
||||||
|
<button class="nv-bell" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<span class="dropdown m-2">
|
<i class="bi bi-bell"></i>
|
||||||
<button class="bg-transparent text-white btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<span class="nv-badge" id="notificationCount">0</span>
|
||||||
<span class="badge badge-danger" id="notificationCount">0</span>
|
</button>
|
||||||
</button>
|
<ul class="dropdown-menu dropdown-menu-end nv-notif-dd" id="notificationDropdown">
|
||||||
<ul class="dropdown-menu" id="notificationDropdown">
|
<li><h6 class="dropdown-header"><i class="bi bi-bell me-1"></i> Notifications</h6></li>
|
||||||
<li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<a class="dropdown-item" href="#">
|
<li class="nv-notif-wrap"><div id="notifications"></div></li>
|
||||||
|
</ul>
|
||||||
<div id="notifications">
|
</span>
|
||||||
</div>
|
<span class="dropdown">
|
||||||
</a>
|
<button class="nv-acct" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
</li>
|
<span class="nv-avatar"><i class="bi bi-person-fill"></i></span>
|
||||||
</ul>
|
<span class="nv-acct-label">Account</span>
|
||||||
</span>
|
<i class="bi bi-chevron-down" style="font-size:10px;color:var(--text-muted)"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header"><i class="bi bi-gear me-1"></i> User Management</h6></li>
|
||||||
<span class="dropdown m-2">
|
<li><a class="dropdown-item" asp-controller="Users" asp-action="Index"><i class="bi bi-people"></i> Users</a></li>
|
||||||
<button class="btn btn-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<li><a class="dropdown-item" asp-controller="Roles" asp-action="Index"><i class="bi bi-shield-check"></i> Roles</a></li>
|
||||||
Account
|
<li><hr class="dropdown-divider"></li>
|
||||||
</button>
|
<li>
|
||||||
<ul class="dropdown-menu">
|
<form asp-area="Admin" asp-controller="Admin" asp-action="Logout" method="post" class="d-inline w-100">
|
||||||
<li><a class="dropdown-item" asp-controller="Roles" asp-action="index">Roles</a></li>
|
<button type="submit" class="dropdown-item text-danger w-100" style="border:none;background:none"><i class="bi bi-box-arrow-left"></i> Logout</button>
|
||||||
<li><a class="dropdown-item" asp-controller="Users" asp-action="index">Users</a></li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
<li class="nav-item m-2">
|
|
||||||
<form asp-area="Admin" asp-controller="Admin" asp-action="Logout" method="post">
|
|
||||||
<button type="submit" class="btn btn-danger btn-sm"><span class="bi bi-box-arrow-left"></span> Logout</button>
|
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
|
<partial name="_AccessDeniedModal" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||||
|
|
||||||
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
||||||
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/5.0.11/signalr.min.js"></script>
|
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/5.0.11/signalr.min.js"></script>
|
||||||
<script type="text/javascript">
|
|
||||||
// Establish a connection to the SignalR hub
|
|
||||||
const connection = new signalR.HubConnectionBuilder()
|
|
||||||
.withUrl("/notificationHub")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Function to load notifications from local storage
|
<script>
|
||||||
function loadNotifications() {
|
$(document).ready(function(){
|
||||||
const notificationsList = document.getElementById("notifications");
|
$('#nvkn-toggle').on('click',function(e){
|
||||||
const notificationCount = document.getElementById("notificationCount");
|
e.preventDefault();
|
||||||
|
$('#nvkn-sidebar').toggleClass('expanded');
|
||||||
// Retrieve notifications from local storage
|
|
||||||
const storedNotifications = JSON.parse(localStorage.getItem("notifications")) || [];
|
|
||||||
|
|
||||||
// Update the notification count
|
|
||||||
notificationCount.textContent = storedNotifications.length;
|
|
||||||
|
|
||||||
// Add each stored notification to the list
|
|
||||||
storedNotifications.forEach(notification => {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.className = "list-group-item";
|
|
||||||
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = `/admin/UserResponseStatus/UserResponsesStatus?UserEmail=${notification.email}`;
|
|
||||||
link.textContent = notification.text;
|
|
||||||
link.addEventListener("click", () => {
|
|
||||||
removeNotification(div, notification.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
div.appendChild(link);
|
|
||||||
notificationsList.appendChild(div);
|
|
||||||
});
|
});
|
||||||
}
|
$('#nvkn-overlay').on('click',function(){
|
||||||
|
$('#nvkn-sidebar').removeClass('expanded');
|
||||||
// Function to add a notification to the list
|
});
|
||||||
function addNotification(userName, email) {
|
var p=window.location.pathname.toLowerCase();
|
||||||
const notificationsList = document.getElementById("notifications");
|
$('.nv-link').each(function(){
|
||||||
const notificationCount = document.getElementById("notificationCount");
|
var h=$(this).attr('href');
|
||||||
|
if(h&&p.indexOf(h.toLowerCase())!==-1)$(this).addClass('active');
|
||||||
// Create a unique ID for the notification
|
|
||||||
const notificationId = Date.now();
|
|
||||||
|
|
||||||
// Create notification item
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.className = "list-group-item";
|
|
||||||
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = `/admin/UserResponseStatus/UserResponsesStatus?UserEmail=${email}`;
|
|
||||||
link.textContent = ` ${userName}`;
|
|
||||||
link.addEventListener("click", () => {
|
|
||||||
removeNotification(div, notificationId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
div.appendChild(link);
|
|
||||||
notificationsList.appendChild(div);
|
|
||||||
|
|
||||||
// Update the notification count
|
|
||||||
notificationCount.textContent = parseInt(notificationCount.textContent) + 1;
|
|
||||||
|
|
||||||
// Store the notification in local storage
|
|
||||||
const storedNotifications = JSON.parse(localStorage.getItem("notifications")) || [];
|
|
||||||
storedNotifications.push({ id: notificationId, text: link.textContent, email: email });
|
|
||||||
localStorage.setItem("notifications", JSON.stringify(storedNotifications));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to remove a notification
|
|
||||||
function removeNotification(div, id) {
|
|
||||||
div.remove();
|
|
||||||
|
|
||||||
// Update the notification count
|
|
||||||
const notificationCount = document.getElementById("notificationCount");
|
|
||||||
notificationCount.textContent = parseInt(notificationCount.textContent) - 1;
|
|
||||||
|
|
||||||
// Remove the notification from local storage
|
|
||||||
let storedNotifications = JSON.parse(localStorage.getItem("notifications")) || [];
|
|
||||||
storedNotifications = storedNotifications.filter(notification => notification.id !== id);
|
|
||||||
localStorage.setItem("notifications", JSON.stringify(storedNotifications));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive notification from the server
|
|
||||||
connection.on("ReceiveNotification", function (userName, email) {
|
|
||||||
addNotification(userName, email);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the connection
|
var connection=new signalR.HubConnectionBuilder().withUrl("/notificationHub").build();
|
||||||
connection.start().then(loadNotifications).catch(function (err) {
|
function loadNotifications(){
|
||||||
return console.error(err.toString());
|
var nl=document.getElementById("notifications"),nc=document.getElementById("notificationCount");
|
||||||
});
|
var sn=JSON.parse(localStorage.getItem("notifications"))||[];
|
||||||
|
nc.textContent=sn.length;
|
||||||
|
nc.style.display=sn.length===0?'none':'flex';
|
||||||
|
sn.forEach(function(n){
|
||||||
|
var d=document.createElement("div");d.className="list-group-item";
|
||||||
|
var a=document.createElement("a");
|
||||||
|
a.href="/admin/UserResponseStatus/UserResponsesStatus?UserEmail="+n.email;
|
||||||
|
a.textContent=n.text;
|
||||||
|
a.addEventListener("click",function(){removeNotification(d,n.id)});
|
||||||
|
d.appendChild(a);nl.appendChild(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function addNotification(u,e){
|
||||||
|
var nl=document.getElementById("notifications"),nc=document.getElementById("notificationCount");
|
||||||
|
var id=Date.now(),d=document.createElement("div");d.className="list-group-item";
|
||||||
|
var a=document.createElement("a");
|
||||||
|
a.href="/admin/UserResponseStatus/UserResponsesStatus?UserEmail="+e;
|
||||||
|
a.textContent=" "+u;
|
||||||
|
a.addEventListener("click",function(){removeNotification(d,id)});
|
||||||
|
d.appendChild(a);nl.appendChild(d);
|
||||||
|
var c=parseInt(nc.textContent)+1;nc.textContent=c;nc.style.display='flex';
|
||||||
|
var sn=JSON.parse(localStorage.getItem("notifications"))||[];
|
||||||
|
sn.push({id:id,text:a.textContent,email:e});
|
||||||
|
localStorage.setItem("notifications",JSON.stringify(sn));
|
||||||
|
}
|
||||||
|
function removeNotification(d,id){
|
||||||
|
d.remove();
|
||||||
|
var nc=document.getElementById("notificationCount");
|
||||||
|
var c=parseInt(nc.textContent)-1;nc.textContent=c;
|
||||||
|
if(c<=0)nc.style.display='none';
|
||||||
|
var sn=JSON.parse(localStorage.getItem("notifications"))||[];
|
||||||
|
sn=sn.filter(function(n){return n.id!==id});
|
||||||
|
localStorage.setItem("notifications",JSON.stringify(sn));
|
||||||
|
}
|
||||||
|
connection.on("ReceiveNotification",function(u,e){addNotification(u,e)});
|
||||||
|
connection.start().then(loadNotifications).catch(function(err){console.error(err.toString())});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@await RenderSectionAsync("Scripts", required: false)
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
@await RenderSectionAsync("Styles", required: false)
|
@await RenderSectionAsync("Styles", required: false)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,64 +1,825 @@
|
||||||
@model IEnumerable<RegisterViewModel>
|
@* Views/Admin/Users/Index.cshtml *@
|
||||||
|
@model IEnumerable<RegisterViewModel>
|
||||||
|
@using Web.Authorization
|
||||||
|
@using Web.ViewModel.AccountVM
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "List of users";
|
ViewData["Title"] = "User Management";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
@@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
<div class="container mt-5">
|
:root {
|
||||||
<partial name="_Notification" />
|
--nex-emerald: #34d399;
|
||||||
|
--nex-green: #10b981;
|
||||||
|
--nex-teal: #14b8a6;
|
||||||
|
--nex-lime: #a3e635;
|
||||||
|
--nex-blue: #60a5fa;
|
||||||
|
--nex-purple: #c084fc;
|
||||||
|
--nex-red: #f87171;
|
||||||
|
--nex-amber: #fbbf24;
|
||||||
|
--nex-cyan: #22d3ee;
|
||||||
|
--dark-950: #020617;
|
||||||
|
--dark-900: #0f172a;
|
||||||
|
--dark-800: #1e293b;
|
||||||
|
--dark-700: #334155;
|
||||||
|
--dark-600: #475569;
|
||||||
|
--dark-500: #64748b;
|
||||||
|
--dark-400: #94a3b8;
|
||||||
|
--dark-300: #cbd5e1;
|
||||||
|
--dark-200: #e2e8f0;
|
||||||
|
--glass-bg: rgba(255,255,255,0.04);
|
||||||
|
--glass-border: rgba(255,255,255,0.08);
|
||||||
|
--font-main: 'Space Grotesk', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
<div class="card bg-default mb-3">
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
<div class="card-header">Users</div>
|
body { font-family: var(--font-main); background: var(--dark-900); color: #e2e8f0; overflow-x: hidden; }
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
.nex-bg { position:fixed; inset:0; z-index:-1; overflow:hidden; }
|
||||||
<a asp-action="Register" class="btn btn-primary">Register new user</a>
|
.nex-bg .grid { position:absolute; inset:0; background-image: linear-gradient(rgba(52,211,153,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(52,211,153,0.04) 1px, transparent 1px); background-size:60px 60px; }
|
||||||
</p>
|
.nex-bg .mesh { position:absolute; inset:0; background: radial-gradient(circle at 15% 25%, rgba(52,211,153,0.08) 0%, transparent 50%), radial-gradient(circle at 85% 55%, rgba(96,165,250,0.06) 0%, transparent 50%), radial-gradient(circle at 50% 85%, rgba(192,132,252,0.05) 0%, transparent 50%); }
|
||||||
<h4 class="card-title">Users List</h4>
|
|
||||||
<form asp-action="DeleteSelected" method="post">
|
.page-header { position:relative; z-index:10; padding:2.5rem 0 1.5rem; }
|
||||||
<table class="table table-responsive w-100 d-block d-md-table">
|
.container { max-width:1400px; margin:0 auto; padding:0 2rem; }
|
||||||
<thead class="w-100">
|
|
||||||
<tr>
|
.breadcrumb-nex { display:flex; align-items:center; gap:0.75rem; font-family:var(--font-mono); font-size:0.72rem; text-transform:uppercase; letter-spacing:0.06em; margin-bottom:1.5rem; flex-wrap:wrap; }
|
||||||
<th><input type="checkbox" id="selectAll" onclick="toggleAll(this.checked)" /></th>
|
.breadcrumb-nex a { color:var(--dark-400); text-decoration:none; transition:color .2s; }
|
||||||
<th>First Name</th>
|
.breadcrumb-nex a:hover { color:var(--nex-emerald); }
|
||||||
<th>Last Name</th>
|
.breadcrumb-nex .sep { color:var(--dark-600); font-size:0.55rem; }
|
||||||
<th>Email</th>
|
.breadcrumb-nex .current { color:var(--nex-emerald); }
|
||||||
<th>Roles</th>
|
|
||||||
<th class="text-end">Action</th>
|
.header-row { display:flex; align-items:flex-start; justify-content:space-between; gap:2rem; flex-wrap:wrap; }
|
||||||
</tr>
|
.header-badge { display:inline-flex; align-items:center; gap:0.5rem; padding:0.4rem 1rem; background:rgba(52,211,153,0.1); border:1px solid rgba(52,211,153,0.25); border-radius:50px; font-family:var(--font-mono); font-size:0.65rem; font-weight:600; color:var(--nex-emerald); letter-spacing:0.1em; margin-bottom:1rem; }
|
||||||
</thead>
|
.header-badge .dot { width:6px; height:6px; border-radius:50%; background:var(--nex-emerald); animation:pulse-dot 2s ease infinite; }
|
||||||
<tbody class="w-100">
|
@@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||||
@foreach (var item in Model)
|
|
||||||
|
.header-title { font-size:2.2rem; font-weight:700; color:#f8fafc; line-height:1.2; margin-bottom:0.5rem; }
|
||||||
|
.header-title .grad { background:linear-gradient(135deg, var(--nex-emerald), var(--nex-teal)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||||
|
.header-sub { color:var(--dark-400); font-size:0.9rem; }
|
||||||
|
.header-actions { display:flex; gap:0.75rem; flex-wrap:wrap; align-items:flex-start; padding-top:0.5rem; }
|
||||||
|
|
||||||
|
.h-btn { display:inline-flex; align-items:center; gap:0.5rem; padding:0.6rem 1.3rem; border-radius:10px; font-family:var(--font-main); font-size:0.82rem; font-weight:600; border:none; cursor:pointer; transition:all .25s ease; text-decoration:none; }
|
||||||
|
.h-btn.pri { background:linear-gradient(135deg, var(--nex-emerald), var(--nex-teal)); color:#fff; }
|
||||||
|
.h-btn.pri:hover { transform:translateY(-2px); box-shadow:0 8px 25px rgba(52,211,153,0.3); }
|
||||||
|
.h-btn.sec { background:var(--glass-bg); border:1px solid var(--glass-border); color:#e2e8f0; }
|
||||||
|
.h-btn.sec:hover { background:rgba(52,211,153,0.1); border-color:rgba(52,211,153,0.3); color:var(--nex-emerald); }
|
||||||
|
.h-btn.danger { background:rgba(248,113,113,0.15); border:1px solid rgba(248,113,113,0.3); color:var(--nex-red); }
|
||||||
|
.h-btn.danger:hover { background:rgba(248,113,113,0.25); transform:translateY(-2px); }
|
||||||
|
.h-btn:disabled { opacity:0.5; cursor:not-allowed; transform:none !important; }
|
||||||
|
|
||||||
|
.stats-row { display:grid; grid-template-columns:repeat(4, 1fr); gap:1rem; margin-bottom:2rem; }
|
||||||
|
.stat-chip { background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:14px; padding:1.2rem 1.5rem; display:flex; align-items:center; gap:1rem; transition:all .3s ease; }
|
||||||
|
.stat-chip:hover { border-color:rgba(52,211,153,0.2); transform:translateY(-2px); }
|
||||||
|
.stat-chip .s-icon { width:44px; height:44px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; flex-shrink:0; }
|
||||||
|
.stat-chip .s-icon.green { background:rgba(52,211,153,0.15); color:var(--nex-emerald); }
|
||||||
|
.stat-chip .s-icon.blue { background:rgba(96,165,250,0.15); color:var(--nex-blue); }
|
||||||
|
.stat-chip .s-icon.purple { background:rgba(192,132,252,0.15); color:var(--nex-purple); }
|
||||||
|
.stat-chip .s-icon.amber { background:rgba(251,191,36,0.15); color:var(--nex-amber); }
|
||||||
|
.stat-chip .s-val { font-size:1.6rem; font-weight:700; color:#f8fafc; line-height:1; }
|
||||||
|
.stat-chip .s-lbl { font-size:0.72rem; color:var(--dark-400); text-transform:uppercase; letter-spacing:0.05em; font-family:var(--font-mono); margin-top:2px; }
|
||||||
|
|
||||||
|
.nex-card { background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:16px; backdrop-filter:blur(12px); position:relative; overflow:hidden; }
|
||||||
|
.nex-card .top-glow { position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg, transparent, var(--nex-emerald), transparent); opacity:0.6; }
|
||||||
|
|
||||||
|
.tbl-head { display:flex; align-items:center; justify-content:space-between; padding:1.25rem 1.5rem; border-bottom:1px solid var(--glass-border); flex-wrap:wrap; gap:1rem; }
|
||||||
|
.tbl-title { display:flex; align-items:center; gap:0.75rem; font-size:1rem; font-weight:600; color:#f8fafc; }
|
||||||
|
.tbl-title i { color:var(--nex-emerald); }
|
||||||
|
.tbl-count { font-family:var(--font-mono); font-size:0.7rem; color:var(--dark-400); background:rgba(52,211,153,0.1); padding:0.25rem 0.65rem; border-radius:6px; border:1px solid rgba(52,211,153,0.2); }
|
||||||
|
|
||||||
|
.search-box { display:flex; align-items:center; gap:0.5rem; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:10px; padding:0 1rem; transition:border-color .2s; }
|
||||||
|
.search-box:focus-within { border-color:rgba(52,211,153,0.4); }
|
||||||
|
.search-box i { color:var(--dark-500); font-size:0.85rem; }
|
||||||
|
.search-box input { background:none; border:none; outline:none; color:#e2e8f0; font-family:var(--font-main); font-size:0.82rem; padding:0.55rem 0; width:180px; }
|
||||||
|
.search-box input::placeholder { color:var(--dark-500); }
|
||||||
|
|
||||||
|
.user-table { width:100%; border-collapse:collapse; }
|
||||||
|
.user-table thead th { padding:0.85rem 1.5rem; text-align:left; font-family:var(--font-mono); font-size:0.68rem; font-weight:600; color:var(--dark-400); text-transform:uppercase; letter-spacing:0.08em; border-bottom:1px solid var(--glass-border); background:rgba(255,255,255,0.02); }
|
||||||
|
.user-table thead th:first-child { width:50px; text-align:center; }
|
||||||
|
.user-table thead th:last-child { text-align:right; }
|
||||||
|
.user-table tbody tr { border-bottom:1px solid rgba(255,255,255,0.04); transition:background .2s; }
|
||||||
|
.user-table tbody tr:hover { background:rgba(52,211,153,0.04); }
|
||||||
|
.user-table tbody tr:last-child { border-bottom:none; }
|
||||||
|
.user-table tbody td { padding:0.9rem 1.5rem; font-size:0.88rem; vertical-align:middle; }
|
||||||
|
.user-table tbody td:first-child { text-align:center; }
|
||||||
|
.user-table tbody td:last-child { text-align:right; }
|
||||||
|
|
||||||
|
.nex-check { width:18px; height:18px; accent-color:var(--nex-emerald); cursor:pointer; }
|
||||||
|
|
||||||
|
.user-cell { display:flex; align-items:center; gap:0.85rem; }
|
||||||
|
.user-avatar { width:38px; height:38px; border-radius:10px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.8rem; color:#fff; flex-shrink:0; text-transform:uppercase; }
|
||||||
|
.user-name { font-weight:600; color:#f1f5f9; line-height:1.3; }
|
||||||
|
.user-name small { display:block; font-weight:400; font-size:0.75rem; color:var(--dark-400); margin-top:1px; }
|
||||||
|
|
||||||
|
.role-badge { display:inline-flex; align-items:center; gap:0.3rem; padding:0.25rem 0.7rem; border-radius:6px; font-size:0.72rem; font-weight:600; font-family:var(--font-mono); margin-right:0.3rem; }
|
||||||
|
.role-badge.admin { background:rgba(248,113,113,0.15); color:var(--nex-red); border:1px solid rgba(248,113,113,0.25); }
|
||||||
|
.role-badge.user { background:rgba(96,165,250,0.15); color:var(--nex-blue); border:1px solid rgba(96,165,250,0.25); }
|
||||||
|
.role-badge.manager { background:rgba(251,191,36,0.15); color:var(--nex-amber); border:1px solid rgba(251,191,36,0.25); }
|
||||||
|
.role-badge.default { background:rgba(148,163,184,0.15); color:var(--dark-300); border:1px solid rgba(148,163,184,0.2); }
|
||||||
|
|
||||||
|
.act-btn { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--dark-300); cursor:pointer; transition:all .2s; font-size:0.82rem; margin-left:0.35rem; }
|
||||||
|
.act-btn:hover { transform:translateY(-1px); }
|
||||||
|
.act-btn.edit:hover { background:rgba(96,165,250,0.15); border-color:rgba(96,165,250,0.3); color:var(--nex-blue); }
|
||||||
|
.act-btn.del:hover { background:rgba(248,113,113,0.15); border-color:rgba(248,113,113,0.3); color:var(--nex-red); }
|
||||||
|
|
||||||
|
.tbl-foot { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.5rem; border-top:1px solid var(--glass-border); flex-wrap:wrap; gap:0.75rem; }
|
||||||
|
.selected-info { font-family:var(--font-mono); font-size:0.72rem; color:var(--dark-400); }
|
||||||
|
.selected-info span { color:var(--nex-emerald); font-weight:600; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { display:none; position:fixed; inset:0; z-index:9999; background:rgba(2,6,23,0.8); backdrop-filter:blur(8px); align-items:center; justify-content:center; padding:2rem; }
|
||||||
|
.modal-overlay.active { display:flex; animation:fadeIn .25s ease; }
|
||||||
|
@@keyframes fadeIn { from{opacity:0} to{opacity:1} }
|
||||||
|
|
||||||
|
.modal-box { background:var(--dark-800); border:1px solid var(--glass-border); border-radius:20px; width:100%; max-width:520px; max-height:90vh; overflow-y:auto; position:relative; animation:slideUp .3s ease; }
|
||||||
|
@@keyframes slideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
|
||||||
|
.modal-box .m-glow { position:absolute; top:0; left:0; right:0; height:3px; border-radius:20px 20px 0 0; }
|
||||||
|
.modal-box .m-glow.green { background:linear-gradient(90deg, var(--nex-emerald), var(--nex-teal)); }
|
||||||
|
.modal-box .m-glow.blue { background:linear-gradient(90deg, var(--nex-blue), var(--nex-cyan)); }
|
||||||
|
.modal-box .m-glow.red { background:linear-gradient(90deg, var(--nex-red), #dc2626); }
|
||||||
|
|
||||||
|
.m-header { display:flex; align-items:center; justify-content:space-between; padding:1.5rem 1.75rem 1rem; }
|
||||||
|
.m-header-left { display:flex; align-items:center; gap:0.85rem; }
|
||||||
|
.m-header-icon { width:42px; height:42px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; }
|
||||||
|
.m-header-icon.green { background:rgba(52,211,153,0.15); color:var(--nex-emerald); }
|
||||||
|
.m-header-icon.blue { background:rgba(96,165,250,0.15); color:var(--nex-blue); }
|
||||||
|
.m-header-icon.red { background:rgba(248,113,113,0.15); color:var(--nex-red); }
|
||||||
|
.m-header h3 { font-size:1.1rem; font-weight:700; color:#f8fafc; }
|
||||||
|
.m-header p { font-size:0.78rem; color:var(--dark-400); margin-top:2px; }
|
||||||
|
.m-close { width:36px; height:36px; border-radius:10px; border:1px solid var(--glass-border); background:none; color:var(--dark-400); display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all .2s; font-size:0.9rem; }
|
||||||
|
.m-close:hover { background:rgba(248,113,113,0.15); border-color:rgba(248,113,113,0.3); color:var(--nex-red); }
|
||||||
|
|
||||||
|
.m-body { padding:0.5rem 1.75rem 1.5rem; }
|
||||||
|
|
||||||
|
.f-group { margin-bottom:1.1rem; }
|
||||||
|
.f-label { display:block; font-family:var(--font-mono); font-size:0.7rem; font-weight:600; color:var(--dark-300); text-transform:uppercase; letter-spacing:0.06em; margin-bottom:0.45rem; }
|
||||||
|
.f-input { width:100%; padding:0.65rem 1rem; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:10px; color:#e2e8f0; font-family:var(--font-main); font-size:0.88rem; outline:none; transition:border-color .2s, box-shadow .2s; }
|
||||||
|
.f-input:focus { border-color:rgba(52,211,153,0.5); box-shadow:0 0 0 3px rgba(52,211,153,0.1); }
|
||||||
|
.f-input::placeholder { color:var(--dark-500); }
|
||||||
|
.f-input.error { border-color:rgba(248,113,113,0.5); }
|
||||||
|
.f-row { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }
|
||||||
|
|
||||||
|
.role-chips { display:flex; flex-wrap:wrap; gap:0.5rem; margin-top:0.3rem; }
|
||||||
|
.role-chip { position:relative; }
|
||||||
|
.role-chip input { position:absolute; opacity:0; pointer-events:none; }
|
||||||
|
.role-chip label { display:inline-flex; align-items:center; gap:0.4rem; padding:0.45rem 0.9rem; border-radius:8px; border:1px solid var(--glass-border); background:var(--glass-bg); color:var(--dark-300); font-size:0.78rem; font-weight:600; cursor:pointer; transition:all .2s; }
|
||||||
|
.role-chip input:checked + label { background:rgba(52,211,153,0.15); border-color:rgba(52,211,153,0.4); color:var(--nex-emerald); }
|
||||||
|
.role-chip label:hover { border-color:rgba(52,211,153,0.3); }
|
||||||
|
|
||||||
|
.pw-bar { height:3px; border-radius:2px; background:var(--dark-700); margin-top:0.4rem; overflow:hidden; }
|
||||||
|
.pw-bar .fill { height:100%; border-radius:2px; transition:width .3s, background .3s; width:0; }
|
||||||
|
|
||||||
|
.m-footer { display:flex; align-items:center; justify-content:flex-end; gap:0.75rem; padding:1rem 1.75rem 1.5rem; border-top:1px solid var(--glass-border); }
|
||||||
|
|
||||||
|
/* Error alert in modal */
|
||||||
|
.modal-errors { margin-bottom:1rem; padding:0.75rem 1rem; background:rgba(248,113,113,0.1); border:1px solid rgba(248,113,113,0.25); border-radius:10px; }
|
||||||
|
.modal-errors ul { list-style:none; padding:0; margin:0; }
|
||||||
|
.modal-errors li { font-size:0.8rem; color:var(--nex-red); padding:0.15rem 0; display:flex; align-items:center; gap:0.4rem; }
|
||||||
|
.modal-errors li i { font-size:0.7rem; }
|
||||||
|
|
||||||
|
/* Success toast */
|
||||||
|
.nex-toast { position:fixed; top:1.5rem; right:1.5rem; z-index:99999; padding:0.85rem 1.5rem; border-radius:12px; font-size:0.85rem; font-weight:600; display:flex; align-items:center; gap:0.6rem; animation:slideIn .3s ease, fadeOut .3s ease 2.7s forwards; pointer-events:none; }
|
||||||
|
.nex-toast.success { background:rgba(52,211,153,0.15); border:1px solid rgba(52,211,153,0.3); color:var(--nex-emerald); backdrop-filter:blur(12px); }
|
||||||
|
.nex-toast.error { background:rgba(248,113,113,0.15); border:1px solid rgba(248,113,113,0.3); color:var(--nex-red); backdrop-filter:blur(12px); }
|
||||||
|
@@keyframes slideIn { from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:translateX(0)} }
|
||||||
|
@@keyframes fadeOut { to{opacity:0;transform:translateY(-10px)} }
|
||||||
|
|
||||||
|
/* Delete modal */
|
||||||
|
.del-user-info { display:flex; align-items:center; gap:1rem; padding:1rem 1.25rem; background:rgba(248,113,113,0.08); border:1px solid rgba(248,113,113,0.15); border-radius:12px; margin-bottom:1rem; }
|
||||||
|
.del-avatar { width:48px; height:48px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1rem; color:#fff; flex-shrink:0; }
|
||||||
|
.del-details h4 { font-size:0.95rem; font-weight:600; color:#f8fafc; }
|
||||||
|
.del-details p { font-size:0.8rem; color:var(--dark-400); margin-top:2px; }
|
||||||
|
.del-warning { font-size:0.82rem; color:var(--dark-300); line-height:1.6; }
|
||||||
|
.del-warning strong { color:var(--nex-red); }
|
||||||
|
|
||||||
|
.empty-state { text-align:center; padding:4rem 2rem; }
|
||||||
|
.empty-state i { font-size:3rem; color:var(--dark-600); margin-bottom:1rem; }
|
||||||
|
.empty-state h4 { color:var(--dark-300); font-size:1.1rem; margin-bottom:0.5rem; }
|
||||||
|
.empty-state p { color:var(--dark-500); font-size:0.85rem; }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.btn-spinner { display:none; width:16px; height:16px; border:2px solid rgba(255,255,255,0.3); border-top-color:#fff; border-radius:50%; animation:spin .6s linear infinite; }
|
||||||
|
@@keyframes spin { to{transform:rotate(360deg)} }
|
||||||
|
|
||||||
|
@@media(max-width:1200px) { .stats-row { grid-template-columns:repeat(2, 1fr); } }
|
||||||
|
@@media(max-width:768px) {
|
||||||
|
.header-row { flex-direction:column; }
|
||||||
|
.stats-row { grid-template-columns:1fr; }
|
||||||
|
.f-row { grid-template-columns:1fr; }
|
||||||
|
.tbl-head { flex-direction:column; align-items:flex-start; }
|
||||||
|
.user-table { display:block; overflow-x:auto; }
|
||||||
|
.header-title { font-size:1.6rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="nex-bg"><div class="grid"></div><div class="mesh"></div></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<section class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="breadcrumb-nex">
|
||||||
|
<a href="@Url.Action("Index", "Dashboard")"><i class="fa-solid fa-gauge-high"></i> Admin</a>
|
||||||
|
<i class="fa-solid fa-chevron-right sep"></i>
|
||||||
|
<span class="current">User Management</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-row">
|
||||||
|
<div>
|
||||||
|
<div class="header-badge"><span class="dot"></span> IDENTITY & ACCESS CONTROL</div>
|
||||||
|
<h1 class="header-title">User <span class="grad">Management</span></h1>
|
||||||
|
<p class="header-sub">Manage system users, roles, and access permissions</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="h-btn pri" onclick="if(checkPermission(userPermissions.canCreate)) openCreateModal()">
|
||||||
|
<i class="fa-solid fa-user-plus"></i> New User
|
||||||
|
</button>
|
||||||
|
<button type="button" class="h-btn danger d-none" id="bulkDeleteBtn" onclick="if(checkPermission(userPermissions.canDelete)) confirmBulkDelete()">
|
||||||
|
<i class="fa-solid fa-trash-can"></i> Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<section style="position:relative;z-index:10;padding:0 0 1.5rem">
|
||||||
|
<div class="container">
|
||||||
|
@{
|
||||||
|
var totalUsers = Model.Count();
|
||||||
|
var adminCount = Model.Count(u => u.SelectedRoles != null && u.SelectedRoles.Any(r => r.Equals("Admin", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
var userCount = Model.Count(u => u.SelectedRoles != null && u.SelectedRoles.Any(r => r.Equals("User", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
var roleCount = Model.SelectMany(u => u.SelectedRoles ?? new List<string>()).Distinct().Count();
|
||||||
|
}
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-chip">
|
||||||
|
<div class="s-icon green"><i class="fa-solid fa-users"></i></div>
|
||||||
|
<div><div class="s-val">@totalUsers</div><div class="s-lbl">Total Users</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<div class="s-icon blue"><i class="fa-solid fa-user-shield"></i></div>
|
||||||
|
<div><div class="s-val">@adminCount</div><div class="s-lbl">Admins</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<div class="s-icon purple"><i class="fa-solid fa-user"></i></div>
|
||||||
|
<div><div class="s-val">@userCount</div><div class="s-lbl">Users</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<div class="s-icon amber"><i class="fa-solid fa-key"></i></div>
|
||||||
|
<div><div class="s-val">@roleCount</div><div class="s-lbl">Roles</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<section style="position:relative;z-index:10;padding-bottom:3rem;">
|
||||||
|
<div class="container">
|
||||||
|
<partial name="_Notification" />
|
||||||
|
|
||||||
|
<form asp-action="DeleteSelected" method="post" id="deleteForm">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="nex-card">
|
||||||
|
<div class="top-glow"></div>
|
||||||
|
|
||||||
|
<div class="tbl-head">
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<div class="tbl-title"><i class="fa-solid fa-users-gear"></i> System Users</div>
|
||||||
|
<div class="tbl-count">@totalUsers registered</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
<input type="text" id="searchInput" placeholder="Search users..." oninput="filterTable()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
<table class="user-table" id="userTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" class="nex-check" id="selectAll" onclick="toggleAll(this.checked)" /></th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@{ var colors = new[] { "#6366f1","#ec4899","#f59e0b","#10b981","#3b82f6","#8b5cf6","#ef4444","#06b6d4" }; var idx = 0; }
|
||||||
|
@foreach (var user in Model)
|
||||||
|
{
|
||||||
|
var initials = ((user.FirstName?.Length > 0 ? user.FirstName[0].ToString() : "") + (user.LastName?.Length > 0 ? user.LastName[0].ToString() : "")).ToUpper();
|
||||||
|
var bgColor = colors[idx % colors.Length];
|
||||||
|
var rolesStr = user.SelectedRoles != null ? string.Join(",", user.SelectedRoles) : "";
|
||||||
|
idx++;
|
||||||
|
<tr data-name="@($"{user.FirstName} {user.LastName}".ToLower())" data-email="@user.Email?.ToLower()">
|
||||||
|
<td><input type="checkbox" class="nex-check" name="selectedUserIds" value="@user.Email" onchange="updateSelectionCount()" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="user-cell">
|
||||||
|
<div class="user-avatar" style="background:@bgColor">@initials</div>
|
||||||
|
<div class="user-name">
|
||||||
|
@user.FirstName @user.LastName
|
||||||
|
<small>ID: @(user.Id?.Length > 8 ? user.Id[..8] + "…" : user.Id)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--dark-300)">@user.Email</td>
|
||||||
|
<td>
|
||||||
|
@if (user.SelectedRoles != null)
|
||||||
{
|
{
|
||||||
<tr>
|
@foreach (var role in user.SelectedRoles)
|
||||||
<td><input type="checkbox" name="selectedUserIds" value="@item.Email" /></td>
|
{
|
||||||
|
var roleClass = role.ToLower() switch { "admin" => "admin", "user" => "user", "manager" => "manager", _ => "default" };
|
||||||
<td>@item.FirstName</td>
|
<span class="role-badge @roleClass"><i class="fa-solid fa-circle" style="font-size:0.35rem"></i> @role</span>
|
||||||
<td>@item.LastName</td>
|
}
|
||||||
<td>@item.Email</td>
|
|
||||||
<td class="text-primary font-weight-bold"> @string.Join(", ", item.SelectedRoles)</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-info btn-sm">Edit</a>
|
|
||||||
@* <a asp-action="Edit" asp-controller="Users" asp-route-id="@item.Id">Edit</a> *@
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td>
|
||||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete the selected users?');">Delete Selected</button>
|
<button type="button" class="act-btn edit" title="Edit user"
|
||||||
|
onclick="if(checkPermission(userPermissions.canEdit)) openEditModal('@user.Id', '@Html.Raw(user.FirstName?.Replace("'", "\\'"))', '@Html.Raw(user.LastName?.Replace("'", "\\'"))', '@rolesStr')">
|
||||||
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="act-btn del" title="Delete user"
|
||||||
|
onclick="if(checkPermission(userPermissions.canDelete)) openDeleteModal('@user.Email', '@Html.Raw((user.FirstName + " " + user.LastName).Replace("'", "\\'"))', '@user.Email')">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fa-solid fa-user-slash"></i>
|
||||||
|
<h4>No users found</h4>
|
||||||
|
<p>Create your first user to get started</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="tbl-foot">
|
||||||
|
<div class="selected-info"><span id="selCount">0</span> of @totalUsers selected</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:0.7rem;color:var(--dark-500);">Last updated: @DateTime.Now.ToString("MMM dd, yyyy HH:mm")</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ CREATE MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="createModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="m-glow green"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon green"><i class="fa-solid fa-user-plus"></i></div>
|
||||||
|
<div><h3>Create New User</h3><p>Add a new member to the system</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('createModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<form id="createForm" novalidate>
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="m-body">
|
||||||
|
<div id="createErrors" class="modal-errors" style="display:none"><ul></ul></div>
|
||||||
|
<div class="f-row">
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-user"></i> First Name *</label>
|
||||||
|
<input type="text" name="FirstName" class="f-input" placeholder="Enter first name" required />
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-user"></i> Last Name *</label>
|
||||||
|
<input type="text" name="LastName" class="f-input" placeholder="Enter last name" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-envelope"></i> Email Address *</label>
|
||||||
|
<input type="email" name="Email" class="f-input" placeholder="user@@example.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-lock"></i> Password *</label>
|
||||||
|
<input type="password" name="Password" class="f-input" placeholder="Min. 6 characters" required minlength="6" oninput="checkPwStrength(this)" />
|
||||||
|
<div class="pw-bar"><div class="fill" id="pwStrength"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-lock"></i> Confirm Password *</label>
|
||||||
|
<input type="password" name="ConfirmPassword" class="f-input" placeholder="Re-enter password" required />
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-shield-halved"></i> Assign Roles *</label>
|
||||||
|
<div class="role-chips">
|
||||||
|
@if (ViewBag.Roles != null)
|
||||||
|
{
|
||||||
|
@foreach (var role in (List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Roles)
|
||||||
|
{
|
||||||
|
<div class="role-chip">
|
||||||
|
<input type="checkbox" name="SelectedRoles" value="@role.Value" id="create_role_@role.Value" />
|
||||||
|
<label for="create_role_@role.Value"><i class="fa-solid fa-shield-halved"></i> @role.Text</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('createModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<button type="submit" class="h-btn pri" id="createSubmitBtn">
|
||||||
|
<div class="btn-spinner" id="createSpinner"></div>
|
||||||
|
<i class="fa-solid fa-user-plus" id="createIcon"></i> Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ EDIT MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="editModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="m-glow blue"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon blue"><i class="fa-solid fa-user-pen"></i></div>
|
||||||
|
<div><h3>Edit User</h3><p>Update user details and roles</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('editModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<form id="editForm" novalidate>
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="Id" id="editId" />
|
||||||
|
<div class="m-body">
|
||||||
|
<div id="editErrors" class="modal-errors" style="display:none"><ul></ul></div>
|
||||||
|
<div class="f-row">
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-user"></i> First Name *</label>
|
||||||
|
<input type="text" name="FirstName" id="editFirstName" class="f-input" required />
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-user"></i> Last Name *</label>
|
||||||
|
<input type="text" name="LastName" id="editLastName" class="f-input" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="f-group">
|
||||||
|
<label class="f-label"><i class="fa-solid fa-shield-halved"></i> Assign Roles *</label>
|
||||||
|
<div class="role-chips" id="editRoleChips">
|
||||||
|
@if (ViewBag.Roles != null)
|
||||||
|
{
|
||||||
|
@foreach (var role in (List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Roles)
|
||||||
|
{
|
||||||
|
<div class="role-chip">
|
||||||
|
<input type="checkbox" name="SelectedRoles" value="@role.Value" id="edit_role_@role.Value" />
|
||||||
|
<label for="edit_role_@role.Value"><i class="fa-solid fa-shield-halved"></i> @role.Text</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('editModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<button type="submit" class="h-btn pri" id="editSubmitBtn" style="background:linear-gradient(135deg, var(--nex-blue), var(--nex-cyan))">
|
||||||
|
<div class="btn-spinner" id="editSpinner"></div>
|
||||||
|
<i class="fa-solid fa-floppy-disk" id="editIcon"></i> Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ DELETE MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="deleteModal">
|
||||||
|
<div class="modal-box" style="max-width:460px">
|
||||||
|
<div class="m-glow red"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon red"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
||||||
|
<div><h3>Delete User</h3><p>This action cannot be undone</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('deleteModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="m-body">
|
||||||
|
<div class="del-user-info">
|
||||||
|
<div class="del-avatar" id="delAvatar" style="background:#6366f1">??</div>
|
||||||
|
<div class="del-details">
|
||||||
|
<h4 id="delName">User Name</h4>
|
||||||
|
<p id="delEmail">user@example.com</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="del-warning">
|
||||||
|
<i class="fa-solid fa-circle-exclamation"></i> You are about to <strong>permanently delete</strong> this user account.
|
||||||
|
All associated data, roles, and permissions will be removed. This action is <strong>irreversible</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('deleteModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<form id="singleDeleteForm" asp-action="DeleteSelected" method="post" style="display:inline">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="selectedUserIds" id="delUserId" />
|
||||||
|
<button type="submit" class="h-btn danger"><i class="fa-solid fa-trash-can"></i> Delete User</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ BULK DELETE MODAL ═══════════ -->
|
||||||
|
<div class="modal-overlay" id="bulkDeleteModal">
|
||||||
|
<div class="modal-box" style="max-width:460px">
|
||||||
|
<div class="m-glow red"></div>
|
||||||
|
<div class="m-header">
|
||||||
|
<div class="m-header-left">
|
||||||
|
<div class="m-header-icon red"><i class="fa-solid fa-users-slash"></i></div>
|
||||||
|
<div><h3>Delete Selected Users</h3><p>Bulk removal confirmation</p></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="m-close" onclick="closeModal('bulkDeleteModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="m-body">
|
||||||
|
<p class="del-warning">
|
||||||
|
<i class="fa-solid fa-circle-exclamation"></i> You are about to <strong>permanently delete <span id="bulkCount">0</span> users</strong>
|
||||||
|
from the system. All their data, roles, and permissions will be removed. This is <strong>irreversible</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="m-footer">
|
||||||
|
<button type="button" class="h-btn sec" onclick="closeModal('bulkDeleteModal')"><i class="fa-solid fa-xmark"></i> Cancel</button>
|
||||||
|
<button type="button" class="h-btn danger" onclick="submitBulkDelete()"><i class="fa-solid fa-trash-can"></i> Delete All Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleAll(isChecked) {
|
const userPermissions = {
|
||||||
var checkboxes = document.querySelectorAll('input[name="selectedUserIds"]');
|
canCreate: @User.HasPermission(Permissions.Users.Create).ToString().ToLower(),
|
||||||
checkboxes.forEach(ch => ch.checked = isChecked);
|
canEdit: @User.HasPermission(Permissions.Users.Edit).ToString().ToLower(),
|
||||||
|
canDelete: @User.HasPermission(Permissions.Users.Delete).ToString().ToLower()
|
||||||
|
};
|
||||||
|
const accessDeniedUrl = '@Url.Action("Index", "AccessDenied", new { area = "Admin" })';
|
||||||
|
|
||||||
|
function checkPermission(permission) {
|
||||||
|
if (!permission) {
|
||||||
|
window.location.href = accessDeniedUrl;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
</script>
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// ── Toast ──
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'nex-toast ' + type;
|
||||||
|
const icon = type === 'success' ? 'fa-circle-check' : 'fa-circle-xmark';
|
||||||
|
toast.innerHTML = '<i class="fa-solid ' + icon + '"></i> ' + message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection ──
|
||||||
|
function toggleAll(checked) {
|
||||||
|
document.querySelectorAll('input[name="selectedUserIds"]').forEach(cb => cb.checked = checked);
|
||||||
|
updateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionCount() {
|
||||||
|
const checked = document.querySelectorAll('input[name="selectedUserIds"]:checked').length;
|
||||||
|
document.getElementById('selCount').textContent = checked;
|
||||||
|
const bulkBtn = document.getElementById('bulkDeleteBtn');
|
||||||
|
if (checked > 0) bulkBtn.classList.remove('d-none');
|
||||||
|
else bulkBtn.classList.add('d-none');
|
||||||
|
|
||||||
|
const total = document.querySelectorAll('input[name="selectedUserIds"]').length;
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.indeterminate = checked > 0 && checked < total;
|
||||||
|
selectAll.checked = checked === total && total > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ──
|
||||||
|
function filterTable() {
|
||||||
|
const q = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
document.querySelectorAll('#userTable tbody tr').forEach(row => {
|
||||||
|
const name = row.dataset.name || '';
|
||||||
|
const email = row.dataset.email || '';
|
||||||
|
row.style.display = (name.includes(q) || email.includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal Helpers ──
|
||||||
|
function openModal(id) {
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(overlay.id); });
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.active').forEach(m => closeModal(m.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
function showModalErrors(containerId, errors) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const ul = container.querySelector('ul');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
errors.forEach(err => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = '<i class="fa-solid fa-circle-xmark"></i> ' + err;
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
container.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModalErrors(containerId) {
|
||||||
|
document.getElementById(containerId).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonLoading(btnId, spinnerId, iconId, loading) {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const spinner = document.getElementById(spinnerId);
|
||||||
|
const icon = document.getElementById(iconId);
|
||||||
|
if (loading) {
|
||||||
|
btn.disabled = true;
|
||||||
|
spinner.style.display = 'block';
|
||||||
|
if (icon) icon.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
if (icon) icon.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CREATE via AJAX ──
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('createForm').reset();
|
||||||
|
hideModalErrors('createErrors');
|
||||||
|
openModal('createModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('createForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideModalErrors('createErrors');
|
||||||
|
|
||||||
|
// Client-side validation
|
||||||
|
const form = this;
|
||||||
|
const firstName = form.querySelector('[name="FirstName"]').value.trim();
|
||||||
|
const lastName = form.querySelector('[name="LastName"]').value.trim();
|
||||||
|
const email = form.querySelector('[name="Email"]').value.trim();
|
||||||
|
const password = form.querySelector('[name="Password"]').value;
|
||||||
|
const confirmPassword = form.querySelector('[name="ConfirmPassword"]').value;
|
||||||
|
const roles = form.querySelectorAll('[name="SelectedRoles"]:checked');
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
if (!firstName) errors.push('First Name is required.');
|
||||||
|
if (!lastName) errors.push('Last Name is required.');
|
||||||
|
if (!email) errors.push('Email is required.');
|
||||||
|
if (!password || password.length < 6) errors.push('Password must be at least 6 characters.');
|
||||||
|
if (password !== confirmPassword) errors.push('Passwords do not match.');
|
||||||
|
if (roles.length === 0) errors.push('You must select at least one role.');
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
showModalErrors('createErrors', errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setButtonLoading('createSubmitBtn', 'createSpinner', 'createIcon', true);
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
fetch('@Url.Action("RegisterAjax")', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setButtonLoading('createSubmitBtn', 'createSpinner', 'createIcon', false);
|
||||||
|
if (data.success) {
|
||||||
|
closeModal('createModal');
|
||||||
|
showToast(data.message || 'User created successfully!', 'success');
|
||||||
|
setTimeout(() => location.reload(), 800);
|
||||||
|
} else {
|
||||||
|
showModalErrors('createErrors', data.errors || ['An unknown error occurred.']);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setButtonLoading('createSubmitBtn', 'createSpinner', 'createIcon', false);
|
||||||
|
showModalErrors('createErrors', ['Network error. Please try again.']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── EDIT via AJAX ──
|
||||||
|
function openEditModal(id, firstName, lastName, roles) {
|
||||||
|
document.getElementById('editForm').reset();
|
||||||
|
hideModalErrors('editErrors');
|
||||||
|
|
||||||
|
document.getElementById('editId').value = id;
|
||||||
|
document.getElementById('editFirstName').value = firstName;
|
||||||
|
document.getElementById('editLastName').value = lastName;
|
||||||
|
|
||||||
|
// Reset all role checkboxes
|
||||||
|
document.querySelectorAll('#editRoleChips input').forEach(cb => cb.checked = false);
|
||||||
|
|
||||||
|
// Check matching roles
|
||||||
|
if (roles) {
|
||||||
|
roles.split(',').forEach(r => {
|
||||||
|
const cb = document.getElementById('edit_role_' + r.trim());
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal('editModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideModalErrors('editErrors');
|
||||||
|
|
||||||
|
const form = this;
|
||||||
|
const firstName = form.querySelector('[name="FirstName"]').value.trim();
|
||||||
|
const lastName = form.querySelector('[name="LastName"]').value.trim();
|
||||||
|
const roles = form.querySelectorAll('[name="SelectedRoles"]:checked');
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
if (!firstName) errors.push('First Name is required.');
|
||||||
|
if (!lastName) errors.push('Last Name is required.');
|
||||||
|
if (roles.length === 0) errors.push('You must select at least one role.');
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
showModalErrors('editErrors', errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setButtonLoading('editSubmitBtn', 'editSpinner', 'editIcon', true);
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
fetch('@Url.Action("EditAjax")', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setButtonLoading('editSubmitBtn', 'editSpinner', 'editIcon', false);
|
||||||
|
if (data.success) {
|
||||||
|
closeModal('editModal');
|
||||||
|
showToast(data.message || 'User updated successfully!', 'success');
|
||||||
|
setTimeout(() => location.reload(), 800);
|
||||||
|
} else {
|
||||||
|
showModalErrors('editErrors', data.errors || ['An unknown error occurred.']);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setButtonLoading('editSubmitBtn', 'editSpinner', 'editIcon', false);
|
||||||
|
showModalErrors('editErrors', ['Network error. Please try again.']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE ──
|
||||||
|
function openDeleteModal(email, name, displayEmail) {
|
||||||
|
document.getElementById('delUserId').value = email;
|
||||||
|
document.getElementById('delName').textContent = name;
|
||||||
|
document.getElementById('delEmail').textContent = displayEmail;
|
||||||
|
|
||||||
|
const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
document.getElementById('delAvatar').textContent = initials;
|
||||||
|
|
||||||
|
openModal('deleteModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BULK DELETE ──
|
||||||
|
function confirmBulkDelete() {
|
||||||
|
const count = document.querySelectorAll('input[name="selectedUserIds"]:checked').length;
|
||||||
|
if (count === 0) return;
|
||||||
|
document.getElementById('bulkCount').textContent = count;
|
||||||
|
openModal('bulkDeleteModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBulkDelete() {
|
||||||
|
closeModal('bulkDeleteModal');
|
||||||
|
document.getElementById('deleteForm').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password Strength ──
|
||||||
|
function checkPwStrength(input) {
|
||||||
|
const val = input.value;
|
||||||
|
const bar = document.getElementById('pwStrength');
|
||||||
|
let score = 0;
|
||||||
|
if (val.length >= 6) score++;
|
||||||
|
if (val.length >= 10) score++;
|
||||||
|
if (/[A-Z]/.test(val)) score++;
|
||||||
|
if (/[0-9]/.test(val)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(val)) score++;
|
||||||
|
|
||||||
|
const pct = (score / 5) * 100;
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
bar.style.background = pct <= 40 ? 'var(--nex-red)' : pct <= 70 ? 'var(--nex-amber)' : 'var(--nex-emerald)';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
13
Web/Authorization/HasPermissionAttribute.cs
Normal file
13
Web/Authorization/HasPermissionAttribute.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Authorization/HasPermissionAttribute.cs
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace Web.Authorization
|
||||||
|
{
|
||||||
|
public class HasPermissionAttribute : AuthorizeAttribute
|
||||||
|
{
|
||||||
|
public HasPermissionAttribute(string permission)
|
||||||
|
: base(policy: permission)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Web/Authorization/PermissionAuthorizationHandler.cs
Normal file
87
Web/Authorization/PermissionAuthorizationHandler.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
// Authorization/PermissionAuthorizationHandler.cs
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Web.Authorization
|
||||||
|
{
|
||||||
|
public class PermissionRequirement : IAuthorizationRequirement
|
||||||
|
{
|
||||||
|
public string Permission { get; }
|
||||||
|
|
||||||
|
public PermissionRequirement(string permission)
|
||||||
|
{
|
||||||
|
Permission = permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||||
|
{
|
||||||
|
protected override Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context,
|
||||||
|
PermissionRequirement requirement)
|
||||||
|
{
|
||||||
|
if (context.User == null || !context.User.Identity.IsAuthenticated)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin role bypasses all permission checks
|
||||||
|
if (context.User.IsInRole("Admin"))
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has the required permission claim
|
||||||
|
var hasPermission = context.User.Claims
|
||||||
|
.Any(c => c.Type == Permissions.ClaimType &&
|
||||||
|
c.Value == requirement.Permission);
|
||||||
|
|
||||||
|
if (hasPermission)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This provider auto-creates policies for every permission
|
||||||
|
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||||
|
{
|
||||||
|
private readonly DefaultAuthorizationPolicyProvider _fallbackProvider;
|
||||||
|
|
||||||
|
public PermissionPolicyProvider(Microsoft.Extensions.Options.IOptions<AuthorizationOptions> options)
|
||||||
|
{
|
||||||
|
_fallbackProvider = new DefaultAuthorizationPolicyProvider(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||||
|
{
|
||||||
|
// If the policy name matches a known permission, create a policy for it
|
||||||
|
var allPermissions = Permissions.GetAll();
|
||||||
|
|
||||||
|
if (allPermissions.Contains(policyName))
|
||||||
|
{
|
||||||
|
var policy = new AuthorizationPolicyBuilder()
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.AddRequirements(new PermissionRequirement(policyName))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _fallbackProvider.GetPolicyAsync(policyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
|
||||||
|
{
|
||||||
|
return _fallbackProvider.GetDefaultPolicyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
|
||||||
|
{
|
||||||
|
return _fallbackProvider.GetFallbackPolicyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Web/Authorization/PermissionExtensions.cs
Normal file
16
Web/Authorization/PermissionExtensions.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Web.Authorization
|
||||||
|
{
|
||||||
|
public static class PermissionExtensions
|
||||||
|
{
|
||||||
|
public static bool HasPermission(this ClaimsPrincipal user, string permission)
|
||||||
|
{
|
||||||
|
if (user == null) return false;
|
||||||
|
if (user.IsInRole("Admin")) return true;
|
||||||
|
return user.Claims.Any(c =>
|
||||||
|
c.Type == Permissions.ClaimType &&
|
||||||
|
c.Value == permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
Web/Authorization/Permissions.cs
Normal file
124
Web/Authorization/Permissions.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// Authorization/Permissions.cs
|
||||||
|
namespace Web.Authorization
|
||||||
|
{
|
||||||
|
public static class Permissions
|
||||||
|
{
|
||||||
|
// Each permission is a string constant: "Area.Action"
|
||||||
|
// These get stored as claims in AspNetRoleClaims
|
||||||
|
|
||||||
|
public static class Dashboard
|
||||||
|
{
|
||||||
|
public const string View = "Dashboard.View";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Questionnaires
|
||||||
|
{
|
||||||
|
public const string View = "Questionnaires.View";
|
||||||
|
public const string Create = "Questionnaires.Create";
|
||||||
|
public const string Edit = "Questionnaires.Edit";
|
||||||
|
public const string Delete = "Questionnaires.Delete";
|
||||||
|
public const string Send = "Questionnaires.Send";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SurveyAnalysis
|
||||||
|
{
|
||||||
|
public const string View = "SurveyAnalysis.View";
|
||||||
|
public const string Analyze = "SurveyAnalysis.Analyze";
|
||||||
|
public const string Reports = "SurveyAnalysis.Reports";
|
||||||
|
public const string HighRisk = "SurveyAnalysis.HighRisk";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Users
|
||||||
|
{
|
||||||
|
public const string View = "Users.View";
|
||||||
|
public const string Create = "Users.Create";
|
||||||
|
public const string Edit = "Users.Edit";
|
||||||
|
public const string Delete = "Users.Delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Roles
|
||||||
|
{
|
||||||
|
public const string View = "Roles.View";
|
||||||
|
public const string Create = "Roles.Create";
|
||||||
|
public const string Edit = "Roles.Edit";
|
||||||
|
public const string Delete = "Roles.Delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Responses
|
||||||
|
{
|
||||||
|
public const string View = "Responses.View";
|
||||||
|
public const string Delete = "Responses.Delete";
|
||||||
|
public const string Export = "Responses.Export";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim type used in AspNetRoleClaims
|
||||||
|
public const string ClaimType = "Permission";
|
||||||
|
|
||||||
|
// Helper: get ALL permissions grouped by area (used in UI)
|
||||||
|
public static Dictionary<string, List<PermissionItem>> GetAllGrouped()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, List<PermissionItem>>
|
||||||
|
{
|
||||||
|
["Dashboard"] = new()
|
||||||
|
{
|
||||||
|
new("Dashboard.View", "View Dashboard", "fa-solid fa-gauge-high")
|
||||||
|
},
|
||||||
|
["Questionnaires"] = new()
|
||||||
|
{
|
||||||
|
new("Questionnaires.View", "View Questionnaires", "fa-solid fa-eye"),
|
||||||
|
new("Questionnaires.Create", "Create Questionnaires", "fa-solid fa-plus"),
|
||||||
|
new("Questionnaires.Edit", "Edit Questionnaires", "fa-solid fa-pen-to-square"),
|
||||||
|
new("Questionnaires.Delete", "Delete Questionnaires", "fa-solid fa-trash-can"),
|
||||||
|
new("Questionnaires.Send", "Send Questionnaires", "fa-solid fa-paper-plane")
|
||||||
|
},
|
||||||
|
["Survey Analysis"] = new()
|
||||||
|
{
|
||||||
|
new("SurveyAnalysis.View", "View Analysis", "fa-solid fa-eye"),
|
||||||
|
new("SurveyAnalysis.Analyze", "Run Analysis", "fa-solid fa-brain"),
|
||||||
|
new("SurveyAnalysis.Reports", "Generate Reports", "fa-solid fa-file-lines"),
|
||||||
|
new("SurveyAnalysis.HighRisk", "View High Risk Cases", "fa-solid fa-triangle-exclamation")
|
||||||
|
},
|
||||||
|
["User Management"] = new()
|
||||||
|
{
|
||||||
|
new("Users.View", "View Users", "fa-solid fa-eye"),
|
||||||
|
new("Users.Create", "Create Users", "fa-solid fa-user-plus"),
|
||||||
|
new("Users.Edit", "Edit Users", "fa-solid fa-user-pen"),
|
||||||
|
new("Users.Delete", "Delete Users", "fa-solid fa-user-minus")
|
||||||
|
},
|
||||||
|
["Role Management"] = new()
|
||||||
|
{
|
||||||
|
new("Roles.View", "View Roles", "fa-solid fa-eye"),
|
||||||
|
new("Roles.Create", "Create Roles", "fa-solid fa-plus"),
|
||||||
|
new("Roles.Edit", "Edit Roles", "fa-solid fa-pen-to-square"),
|
||||||
|
new("Roles.Delete", "Delete Roles", "fa-solid fa-trash-can")
|
||||||
|
},
|
||||||
|
["Responses"] = new()
|
||||||
|
{
|
||||||
|
new("Responses.View", "View Responses", "fa-solid fa-eye"),
|
||||||
|
new("Responses.Delete", "Delete Responses", "fa-solid fa-trash-can"),
|
||||||
|
new("Responses.Export", "Export Responses", "fa-solid fa-file-export")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: get ALL permission values as flat list
|
||||||
|
public static List<string> GetAll()
|
||||||
|
{
|
||||||
|
return GetAllGrouped().Values.SelectMany(g => g.Select(p => p.Value)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PermissionItem
|
||||||
|
{
|
||||||
|
public string Value { get; set; }
|
||||||
|
public string DisplayName { get; set; }
|
||||||
|
public string Icon { get; set; }
|
||||||
|
|
||||||
|
public PermissionItem(string value, string displayName, string icon)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
DisplayName = displayName;
|
||||||
|
Icon = icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -346,7 +346,7 @@ namespace Web.Controllers
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style='text-align: center; margin: 30px 0;'>
|
<div style='text-align: center; margin: 30px 0;'>
|
||||||
<a href='https://unabused-terina-wavier.ngrok-free.dev/Account/Login' class='btn'>
|
<a href='https://18fd-2a0d-e487-311f-699e-4819-e39c-a8df-8179.ngrok-free.app/Account/Login' class='btn'>
|
||||||
🚀 Login to A-Survey
|
🚀 Login to A-Survey
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -472,8 +472,8 @@ namespace Web.Controllers
|
||||||
{
|
{
|
||||||
Id = a.Id,
|
Id = a.Id,
|
||||||
Text = a.Text,
|
Text = a.Text,
|
||||||
IsOtherOption = a.IsOtherOption, // ← ADD THIS LINE!
|
IsOtherOption = a.IsOtherOption, // NEW
|
||||||
ConditionJson = a.ConditionJson
|
ConditionJson = a.ConditionJson // NEW
|
||||||
}).ToList()
|
}).ToList()
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
|
|
@ -481,8 +481,6 @@ namespace Web.Controllers
|
||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1047
Web/Migrations/20260227020749_AddAnalysisCacheTables.Designer.cs
generated
Normal file
1047
Web/Migrations/20260227020749_AddAnalysisCacheTables.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
114
Web/Migrations/20260227020749_AddAnalysisCacheTables.cs
Normal file
114
Web/Migrations/20260227020749_AddAnalysisCacheTables.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAnalysisCacheTables : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "QuestionnaireAnalysisSnapshots",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
QuestionnaireId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalResponses = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AnalyzedResponses = table.Column<int>(type: "int", nullable: false),
|
||||||
|
OverallPositiveSentiment = table.Column<double>(type: "float", nullable: false),
|
||||||
|
OverallNegativeSentiment = table.Column<double>(type: "float", nullable: false),
|
||||||
|
OverallNeutralSentiment = table.Column<double>(type: "float", nullable: false),
|
||||||
|
LowRiskCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ModerateRiskCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
HighRiskCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CriticalRiskCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ExecutiveSummary = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
TopWorkplaceIssuesJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
MostCommonKeyPhrasesJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
GeneratedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_QuestionnaireAnalysisSnapshots", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_QuestionnaireAnalysisSnapshots_Questionnaires_QuestionnaireId",
|
||||||
|
column: x => x.QuestionnaireId,
|
||||||
|
principalTable: "Questionnaires",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ResponseAnalyses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
ResponseId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
QuestionId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
QuestionText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
AnonymizedText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
SentimentLabel = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
SentimentConfidence = table.Column<double>(type: "float", nullable: false),
|
||||||
|
PositiveScore = table.Column<double>(type: "float", nullable: false),
|
||||||
|
NegativeScore = table.Column<double>(type: "float", nullable: false),
|
||||||
|
NeutralScore = table.Column<double>(type: "float", nullable: false),
|
||||||
|
RiskLevel = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
RiskScore = table.Column<double>(type: "float", nullable: false),
|
||||||
|
RequiresImmediateAttention = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
RecommendedAction = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
RiskIndicatorsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
ProtectiveFactorsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
KeyPhrasesJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
WorkplaceFactorsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
EmotionalIndicatorsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
InsightsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CategoriesJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
AnalyzedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ResponseAnalyses", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ResponseAnalyses_Questions_QuestionId",
|
||||||
|
column: x => x.QuestionId,
|
||||||
|
principalTable: "Questions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ResponseAnalyses_Responses_ResponseId",
|
||||||
|
column: x => x.ResponseId,
|
||||||
|
principalTable: "Responses",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuestionnaireAnalysisSnapshots_QuestionnaireId",
|
||||||
|
table: "QuestionnaireAnalysisSnapshots",
|
||||||
|
column: "QuestionnaireId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ResponseAnalyses_QuestionId",
|
||||||
|
table: "ResponseAnalyses",
|
||||||
|
column: "QuestionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ResponseAnalyses_ResponseId",
|
||||||
|
table: "ResponseAnalyses",
|
||||||
|
column: "ResponseId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "QuestionnaireAnalysisSnapshots");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ResponseAnalyses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1236
Web/Migrations/20260303005443_AddCaseManagement.Designer.cs
generated
Normal file
1236
Web/Migrations/20260303005443_AddCaseManagement.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
146
Web/Migrations/20260303005443_AddCaseManagement.cs
Normal file
146
Web/Migrations/20260303005443_AddCaseManagement.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCaseManagement : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ActionPlans",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
ResponseId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
ActionType = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Priority = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
AssignedTo = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
AssignedToEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
ScheduledDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CompletedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CompletionNotes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CreatedByName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CreatedByEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ActionPlans", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ActionPlans_Responses_ResponseId",
|
||||||
|
column: x => x.ResponseId,
|
||||||
|
principalTable: "Responses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CaseNotes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
ResponseId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AuthorName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
AuthorEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
NoteText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Category = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
IsConfidential = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CaseNotes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CaseNotes_Responses_ResponseId",
|
||||||
|
column: x => x.ResponseId,
|
||||||
|
principalTable: "Responses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CaseStatusEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
ResponseId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ChangedByName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
ChangedByEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Reason = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
ChangedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CaseStatusEntries", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CaseStatusEntries_Responses_ResponseId",
|
||||||
|
column: x => x.ResponseId,
|
||||||
|
principalTable: "Responses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ResponseAnswers_AnswerId",
|
||||||
|
table: "ResponseAnswers",
|
||||||
|
column: "AnswerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ActionPlans_ResponseId",
|
||||||
|
table: "ActionPlans",
|
||||||
|
column: "ResponseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CaseNotes_ResponseId",
|
||||||
|
table: "CaseNotes",
|
||||||
|
column: "ResponseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CaseStatusEntries_ResponseId",
|
||||||
|
table: "CaseStatusEntries",
|
||||||
|
column: "ResponseId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ResponseAnswers_Answers_AnswerId",
|
||||||
|
table: "ResponseAnswers",
|
||||||
|
column: "AnswerId",
|
||||||
|
principalTable: "Answers",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ResponseAnswers_Answers_AnswerId",
|
||||||
|
table: "ResponseAnswers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ActionPlans");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CaseNotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CaseStatusEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ResponseAnswers_AnswerId",
|
||||||
|
table: "ResponseAnswers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1273
Web/Migrations/20260306033732_AddUserTrajectoryCache.Designer.cs
generated
Normal file
1273
Web/Migrations/20260306033732_AddUserTrajectoryCache.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
41
Web/Migrations/20260306033732_AddUserTrajectoryCache.cs
Normal file
41
Web/Migrations/20260306033732_AddUserTrajectoryCache.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Web.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserTrajectoryCache : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UserTrajectoryCaches",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
UserEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
AnalyzedResponseCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
LastResponseDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
TrajectoryJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
PreviousSummary = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UserTrajectoryCaches", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UserTrajectoryCaches");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -155,6 +155,71 @@ namespace Web.Migrations
|
||||||
b.ToTable("AspNetUserTokens", (string)null);
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.ActionPlan", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ActionType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedToEmail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CompletionNotes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedByEmail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedByName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("ResponseId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ScheduledDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ResponseId");
|
||||||
|
|
||||||
|
b.ToTable("ActionPlans");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.Address", b =>
|
modelBuilder.Entity("Model.Address", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -329,6 +394,81 @@ namespace Web.Migrations
|
||||||
b.ToTable("Banners");
|
b.ToTable("Banners");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.CaseNote", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("AuthorEmail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("AuthorName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("IsConfidential")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("NoteText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("ResponseId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ResponseId");
|
||||||
|
|
||||||
|
b.ToTable("CaseNotes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.CaseStatusEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ChangedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("ChangedByEmail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ChangedByName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("ResponseId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ResponseId");
|
||||||
|
|
||||||
|
b.ToTable("CaseStatusEntries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.Footer", b =>
|
modelBuilder.Entity("Model.Footer", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -487,6 +627,66 @@ namespace Web.Migrations
|
||||||
b.ToTable("Questionnaires");
|
b.ToTable("Questionnaires");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.QuestionnaireAnalysisSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AnalyzedResponses")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("CriticalRiskCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ExecutiveSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("GeneratedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("HighRiskCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("LowRiskCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("ModerateRiskCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("MostCommonKeyPhrasesJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<double>("OverallNegativeSentiment")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<double>("OverallNeutralSentiment")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<double>("OverallPositiveSentiment")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<int>("QuestionnaireId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TopWorkplaceIssuesJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("TotalResponses")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("QuestionnaireId");
|
||||||
|
|
||||||
|
b.ToTable("QuestionnaireAnalysisSnapshots");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.Response", b =>
|
modelBuilder.Entity("Model.Response", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -514,6 +714,98 @@ namespace Web.Migrations
|
||||||
b.ToTable("Responses");
|
b.ToTable("Responses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.ResponseAnalysis", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("AnalyzedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("AnonymizedText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CategoriesJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("EmotionalIndicatorsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("InsightsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("KeyPhrasesJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<double>("NegativeScore")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<double>("NeutralScore")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<double>("PositiveScore")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<string>("ProtectiveFactorsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("QuestionId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("QuestionText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("RecommendedAction")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("RequiresImmediateAttention")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("ResponseId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("RiskIndicatorsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("RiskLevel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<double>("RiskScore")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<double>("SentimentConfidence")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<string>("SentimentLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("WorkplaceFactorsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("QuestionId");
|
||||||
|
|
||||||
|
b.HasIndex("ResponseId");
|
||||||
|
|
||||||
|
b.ToTable("ResponseAnalyses");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.ResponseAnswer", b =>
|
modelBuilder.Entity("Model.ResponseAnswer", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -530,6 +822,8 @@ namespace Web.Migrations
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AnswerId");
|
||||||
|
|
||||||
b.HasIndex("ResponseDetailId");
|
b.HasIndex("ResponseDetailId");
|
||||||
|
|
||||||
b.ToTable("ResponseAnswers");
|
b.ToTable("ResponseAnswers");
|
||||||
|
|
@ -675,6 +969,43 @@ namespace Web.Migrations
|
||||||
b.ToTable("Subscriptions");
|
b.ToTable("Subscriptions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.UserTrajectoryCache", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AnalyzedResponseCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastResponseDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("PreviousSummary")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("TrajectoryJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UserEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("UserTrajectoryCaches");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
|
|
@ -726,6 +1057,17 @@ namespace Web.Migrations
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.ActionPlan", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Model.Response", "Response")
|
||||||
|
.WithMany("ActionPlans")
|
||||||
|
.HasForeignKey("ResponseId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Response");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.Answer", b =>
|
modelBuilder.Entity("Model.Answer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Model.Question", "Question")
|
b.HasOne("Model.Question", "Question")
|
||||||
|
|
@ -737,6 +1079,28 @@ namespace Web.Migrations
|
||||||
b.Navigation("Question");
|
b.Navigation("Question");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.CaseNote", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Model.Response", "Response")
|
||||||
|
.WithMany("CaseNotes")
|
||||||
|
.HasForeignKey("ResponseId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Response");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.CaseStatusEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Model.Response", "Response")
|
||||||
|
.WithMany("StatusHistory")
|
||||||
|
.HasForeignKey("ResponseId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Response");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.FooterSocialMedia", b =>
|
modelBuilder.Entity("Model.FooterSocialMedia", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Model.Footer", "Footer")
|
b.HasOne("Model.Footer", "Footer")
|
||||||
|
|
@ -786,6 +1150,17 @@ namespace Web.Migrations
|
||||||
b.Navigation("Questionnaire");
|
b.Navigation("Questionnaire");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.QuestionnaireAnalysisSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Model.Questionnaire", "Questionnaire")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("QuestionnaireId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Questionnaire");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.Response", b =>
|
modelBuilder.Entity("Model.Response", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Model.Questionnaire", "Questionnaire")
|
b.HasOne("Model.Questionnaire", "Questionnaire")
|
||||||
|
|
@ -797,14 +1172,41 @@ namespace Web.Migrations
|
||||||
b.Navigation("Questionnaire");
|
b.Navigation("Questionnaire");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Model.ResponseAnalysis", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Model.Question", "Question")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("QuestionId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Model.Response", "Response")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ResponseId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Question");
|
||||||
|
|
||||||
|
b.Navigation("Response");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.ResponseAnswer", b =>
|
modelBuilder.Entity("Model.ResponseAnswer", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("Model.Answer", "Answer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AnswerId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("Model.ResponseDetail", "ResponseDetail")
|
b.HasOne("Model.ResponseDetail", "ResponseDetail")
|
||||||
.WithMany("ResponseAnswers")
|
.WithMany("ResponseAnswers")
|
||||||
.HasForeignKey("ResponseDetailId")
|
.HasForeignKey("ResponseDetailId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Answer");
|
||||||
|
|
||||||
b.Navigation("ResponseDetail");
|
b.Navigation("ResponseDetail");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -844,7 +1246,13 @@ namespace Web.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("Model.Response", b =>
|
modelBuilder.Entity("Model.Response", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("ActionPlans");
|
||||||
|
|
||||||
|
b.Navigation("CaseNotes");
|
||||||
|
|
||||||
b.Navigation("ResponseDetails");
|
b.Navigation("ResponseDetails");
|
||||||
|
|
||||||
|
b.Navigation("StatusHistory");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Model.ResponseDetail", b =>
|
modelBuilder.Entity("Model.ResponseDetail", b =>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
using Data;
|
using Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Services.Implemnetation;
|
using Services.Implemnetation;
|
||||||
|
using Services.Interaces;
|
||||||
|
using Web.Authorization;
|
||||||
using Web.Extesions;
|
using Web.Extesions;
|
||||||
using Web.ViewComponents;
|
using Web.ViewComponents;
|
||||||
|
|
||||||
|
|
@ -39,8 +42,10 @@ builder.Services.UserResponseConfiguration();
|
||||||
builder.Services.ConfigureOpenAI(config);
|
builder.Services.ConfigureOpenAI(config);
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
builder.Services.ConfigureAIAnalysis();
|
builder.Services.ConfigureAIAnalysis();
|
||||||
|
// Add permission-based authorization
|
||||||
|
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
||||||
|
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||||
|
builder.Services.AddScoped<IUserTrajectoryService, UserTrajectoryService>();
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AllowSeoSoft", policy =>
|
options.AddPolicy("AllowSeoSoft", policy =>
|
||||||
|
|
@ -51,6 +56,33 @@ builder.Services.AddCors(options =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
|
{
|
||||||
|
options.AccessDeniedPath = "/Admin/AccessDenied/Index";
|
||||||
|
options.Events.OnRedirectToAccessDenied = context =>
|
||||||
|
{
|
||||||
|
// For AJAX requests, return 403
|
||||||
|
if (context.Request.Headers["X-Requested-With"] == "XMLHttpRequest" ||
|
||||||
|
context.Request.Headers["Accept"].ToString().Contains("application/json"))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 403;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For normal requests, redirect back with ?accessDenied=true
|
||||||
|
var returnUrl = context.Request.Path + context.Request.QueryString;
|
||||||
|
var referer = context.Request.Headers["Referer"].ToString();
|
||||||
|
var redirectUrl = !string.IsNullOrEmpty(referer) ? referer : "/admin";
|
||||||
|
|
||||||
|
if (!redirectUrl.Contains("accessDenied=true"))
|
||||||
|
{
|
||||||
|
redirectUrl += (redirectUrl.Contains("?") ? "&" : "?") + "accessDenied=true";
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.Redirect(redirectUrl);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
});
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ namespace Web.ViewModel.AccountVM
|
||||||
{
|
{
|
||||||
public class RoleViewModel
|
public class RoleViewModel
|
||||||
{
|
{
|
||||||
public string? Id { get; set; } // Role ID, useful for edits
|
public string? Id { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[Display(Name = "Role Name")]
|
[Display(Name = "Role Name")]
|
||||||
public string? Name { get; set; } // Role name
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public List<string>? SelectedPermissions { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
{
|
{
|
||||||
public class ResponseAnswerViewModel
|
public class ResponseAnswerViewModel
|
||||||
{
|
{
|
||||||
public int Id { get; set; } // Answer ID
|
public int Id { get; set; }
|
||||||
public string? Text { get; set; } // Answer text
|
public string? Text { get; set; }
|
||||||
|
|
||||||
public int? Count { get; set; }
|
public int? Count { get; set; }
|
||||||
|
|
||||||
|
// NEW: Must be mapped from Answer entity
|
||||||
public bool IsOtherOption { get; set; } = false;
|
public bool IsOtherOption { get; set; } = false;
|
||||||
public string? ConditionJson { get; set; } // Add this line for conditional logic
|
public string? ConditionJson { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,473 +5,537 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
@@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
#BannerBackground {
|
:root {
|
||||||
font-family: 'Inter', sans-serif;
|
--nex-blue: #3b82f6;
|
||||||
background: linear-gradient(135deg, #000000 0%, #0a0e1a 30%, #000000 70%, #000101 100%);
|
--nex-teal: #14b8a6;
|
||||||
color: white;
|
--nex-cyan: #22d3ee;
|
||||||
|
--nex-emerald: #34d399;
|
||||||
|
--nex-purple: #a78bfa;
|
||||||
|
--nex-red: #f87171;
|
||||||
|
--dark-950: #020617;
|
||||||
|
--dark-900: #0f172a;
|
||||||
|
--dark-800: #1e293b;
|
||||||
|
--dark-700: #334155;
|
||||||
|
--dark-600: #475569;
|
||||||
|
--dark-500: #64748b;
|
||||||
|
--dark-400: #94a3b8;
|
||||||
|
--dark-300: #cbd5e1;
|
||||||
|
--font-main: 'Space Grotesk', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
--glass-bg: rgba(255,255,255,0.04);
|
||||||
|
--glass-border: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body, html { height: 100%; }
|
||||||
|
|
||||||
|
#loginWrapper {
|
||||||
|
font-family: var(--font-main);
|
||||||
|
background: var(--dark-950);
|
||||||
|
color: #e2e8f0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animated Background ── */
|
||||||
|
.bg-grid {
|
||||||
|
position: absolute; inset: 0; z-index: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(59,130,246,0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59,130,246,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-mesh {
|
||||||
|
position: absolute; inset: 0; z-index: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 30%, rgba(59,130,246,0.12) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 70%, rgba(20,184,166,0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 50% 50%, rgba(167,139,250,0.05) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating orbs */
|
||||||
|
.orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60px);
|
||||||
|
z-index: 0;
|
||||||
|
animation: orbFloat 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.orb-1 { width: 300px; height: 300px; background: rgba(59,130,246,0.15); top: -5%; left: -5%; animation-delay: 0s; }
|
||||||
|
.orb-2 { width: 250px; height: 250px; background: rgba(20,184,166,0.1); bottom: -10%; right: -5%; animation-delay: -7s; }
|
||||||
|
.orb-3 { width: 200px; height: 200px; background: rgba(167,139,250,0.08); top: 50%; left: 60%; animation-delay: -14s; }
|
||||||
|
|
||||||
|
@@keyframes orbFloat {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
33% { transform: translate(30px, -20px) scale(1.05); }
|
||||||
|
66% { transform: translate(-20px, 15px) scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login Card ── */
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
backdrop-filter: blur(30px) saturate(1.5);
|
||||||
|
border: 1px solid rgba(59,130,246,0.15);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2.75rem 2.5rem;
|
||||||
|
box-shadow:
|
||||||
|
0 25px 60px rgba(0,0,0,0.5),
|
||||||
|
0 0 80px rgba(59,130,246,0.06),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--nex-blue), var(--nex-teal), var(--nex-purple));
|
||||||
|
border-radius: 24px 24px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logo / Brand ── */
|
||||||
|
.login-brand {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 60px; height: 60px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(20,184,166,0.15));
|
||||||
|
border: 1px solid rgba(59,130,246,0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1.25rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--nex-blue);
|
||||||
|
animation: brandPulse 3s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes brandPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(59,130,246,0.2); }
|
||||||
|
50% { box-shadow: 0 0 0 12px rgba(59,130,246,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
background: rgba(59,130,246,0.08);
|
||||||
|
border: 1px solid rgba(59,130,246,0.15);
|
||||||
|
border-radius: 50px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nex-blue);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-badge .dot {
|
||||||
|
width: 5px; height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nex-emerald);
|
||||||
|
animation: dotPulse 2s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes dotPulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f8fafc;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title .grad {
|
||||||
|
background: linear-gradient(135deg, var(--nex-blue), var(--nex-teal));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sub {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--dark-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notifications ── */
|
||||||
|
.alert {
|
||||||
|
background: rgba(59,130,246,0.1) !important;
|
||||||
|
border: 1px solid rgba(59,130,246,0.25) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Validation ── */
|
||||||
|
.validation-summary-errors {
|
||||||
|
background: rgba(248,113,113,0.1) !important;
|
||||||
|
border: 1px solid rgba(248,113,113,0.25) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
padding: 0.85rem 1rem !important;
|
||||||
|
margin-bottom: 1.25rem !important;
|
||||||
|
}
|
||||||
|
.validation-summary-errors ul { margin: 0 !important; padding: 0 !important; list-style: none !important; }
|
||||||
|
.validation-summary-errors li { color: var(--nex-red) !important; font-size: 0.8rem !important; font-weight: 500 !important; padding: 0.15rem 0; display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
.validation-summary-errors li::before { content: '\f06a'; font-family: 'Font Awesome 6 Free'; font-weight: 900; font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: var(--nex-red) !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
margin-top: 0.35rem !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form ── */
|
||||||
|
.f-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark-300);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-label i {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--nex-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem 0.75rem 2.75rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: var(--font-main);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-input:focus {
|
||||||
|
border-color: rgba(59,130,246,0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59,130,246,0.1), 0 0 20px rgba(59,130,246,0.05);
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-input::placeholder {
|
||||||
|
color: var(--dark-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autofill fix */
|
||||||
|
.f-input:-webkit-autofill,
|
||||||
|
.f-input:-webkit-autofill:hover,
|
||||||
|
.f-input:-webkit-autofill:focus {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px rgba(15,23,42,0.95) inset !important;
|
||||||
|
-webkit-text-fill-color: #e2e8f0 !important;
|
||||||
|
transition: background-color 5000s ease-in-out 0s !important;
|
||||||
|
border-color: rgba(59,130,246,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input with icon wrapper */
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--dark-500);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: color 0.25s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap:focus-within .input-icon {
|
||||||
|
color: var(--nex-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password toggle */
|
||||||
|
.pw-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--dark-500);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toggle:hover { color: var(--nex-blue); }
|
||||||
|
|
||||||
|
/* ── Remember Me ── */
|
||||||
|
.remember-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 1.25rem 0 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nex-check-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nex-check-wrap input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--nex-blue);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nex-check-wrap label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--dark-300);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-link {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--nex-blue);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-link:hover {
|
||||||
|
color: var(--nex-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit Button ── */
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: linear-gradient(135deg, var(--nex-blue), var(--nex-teal));
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-main);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 0;
|
gap: 0.6rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#BannerBackground::before {
|
.login-btn::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0; left: -100%;
|
||||||
left: 0;
|
width: 100%; height: 100%;
|
||||||
right: 0;
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
|
||||||
bottom: 0;
|
transition: left 0.5s ease;
|
||||||
background: radial-gradient(circle at 15% 25%, rgba(59, 130, 246, 0.08) 0%, transparent 35%), radial-gradient(circle at 85% 75%, rgba(20, 184, 166, 0.06) 0%, transparent 35%), radial-gradient(circle at 50% 50%, rgba(249, 115, 22, 0.04) 0%, transparent 40%);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating elements animation */
|
.login-btn:hover::before { left: 100%; }
|
||||||
.floating-elements {
|
|
||||||
position: absolute;
|
.login-btn:hover {
|
||||||
top: 0;
|
transform: translateY(-2px);
|
||||||
left: 0;
|
box-shadow: 0 12px 35px rgba(59,130,246,0.3), 0 0 50px rgba(59,130,246,0.1);
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-element {
|
.login-btn:active {
|
||||||
position: absolute;
|
transform: translateY(0);
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(45deg, rgba(59, 130, 246, 0.1), rgba(20, 184, 166, 0.08));
|
|
||||||
animation: float 20s infinite linear;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-element:nth-child(1) {
|
/* ── Divider ── */
|
||||||
width: 100px;
|
.login-divider {
|
||||||
height: 100px;
|
|
||||||
top: 10%;
|
|
||||||
left: 10%;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-element:nth-child(2) {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
top: 70%;
|
|
||||||
right: 10%;
|
|
||||||
animation-delay: -7s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-element:nth-child(3) {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
bottom: 20%;
|
|
||||||
left: 20%;
|
|
||||||
animation-delay: -14s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@keyframes float {
|
|
||||||
0%, 100%
|
|
||||||
|
|
||||||
{
|
|
||||||
transform: translateY(0) rotate(0deg);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: translateY(-20px) rotate(180deg);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main login container */
|
|
||||||
.login-container {
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 450px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern glassmorphic login card */
|
|
||||||
.login-card {
|
|
||||||
background: rgba(5, 8, 18, 0.85);
|
|
||||||
backdrop-filter: blur(25px) saturate(1.8);
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 40px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4), 0 0 80px rgba(59, 130, 246, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06) 0%, transparent 30%, rgba(20, 184, 166, 0.04) 70%, rgba(249, 115, 22, 0.02) 100%);
|
|
||||||
border-radius: inherit;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Login header */
|
|
||||||
.login-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
font-size: 2.2rem;
|
|
||||||
font-weight: 900;
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #14b8a6 50%, #f97316 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-subtitle {
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern form group styling */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form labels */
|
|
||||||
.form-label {
|
|
||||||
color: rgba(255, 255, 255, 0.8) !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
font-size: 0.9rem !important;
|
|
||||||
margin-bottom: 8px !important;
|
|
||||||
display: block !important;
|
|
||||||
letter-spacing: 0.3px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern form control styling */
|
|
||||||
.form-control {
|
|
||||||
background: rgba(255, 255, 255, 0.08) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
|
||||||
border-radius: 12px !important;
|
|
||||||
color: rgba(255, 255, 255, 0.95) !important;
|
|
||||||
padding: 16px 20px !important;
|
|
||||||
font-size: 1rem !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
height: 56px !important;
|
|
||||||
line-height: 1.5 !important;
|
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1) !important;
|
|
||||||
outline: none !important;
|
|
||||||
-webkit-appearance: none !important;
|
|
||||||
-moz-appearance: none !important;
|
|
||||||
appearance: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove autofill styling */
|
|
||||||
.form-control:-webkit-autofill,
|
|
||||||
.form-control:-webkit-autofill:hover,
|
|
||||||
.form-control:-webkit-autofill:focus {
|
|
||||||
-webkit-box-shadow: 0 0 0 1000px rgba(255, 255, 255, 0.08) inset !important;
|
|
||||||
-webkit-text-fill-color: rgba(255, 255, 255, 0.95) !important;
|
|
||||||
transition: background-color 5000s ease-in-out 0s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
background: rgba(255, 255, 255, 0.12) !important;
|
|
||||||
border-color: rgba(59, 130, 246, 0.6) !important;
|
|
||||||
color: rgba(255, 255, 255, 1) !important;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15), 0 0 30px rgba(59, 130, 246, 0.1), inset 0 1px 2px rgba(0, 0, 0, 0.1) !important;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.4) !important;
|
|
||||||
font-weight: 400 !important;
|
|
||||||
font-size: 0.95rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern checkbox styling */
|
|
||||||
.form-check {
|
|
||||||
margin: 20px 0 24px 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 1rem;
|
||||||
|
margin: 1.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-check-input {
|
.login-divider::before,
|
||||||
background: rgba(255, 255, 255, 0.08) !important;
|
.login-divider::after {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
content: '';
|
||||||
border-radius: 6px !important;
|
flex: 1;
|
||||||
width: 18px !important;
|
height: 1px;
|
||||||
height: 18px !important;
|
background: var(--glass-border);
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-check-input:checked {
|
.login-divider span {
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #14b8a6 100%) !important;
|
font-family: var(--font-mono);
|
||||||
border-color: rgba(59, 130, 246, 0.6) !important;
|
font-size: 0.65rem;
|
||||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
|
color: var(--dark-500);
|
||||||
}
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
.form-check-input:focus {
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15) !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-label {
|
|
||||||
color: rgba(255, 255, 255, 0.8) !important;
|
|
||||||
font-size: 0.9rem !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
user-select: none !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modern submit button */
|
/* ── Footer Info ── */
|
||||||
.login-submit-btn {
|
.login-footer {
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #14b8a6 50%, var(--bs-primary-text-emphasis) 100%) !important;
|
text-align: center;
|
||||||
border: none !important;
|
margin-top: 1.5rem;
|
||||||
border-radius: 12px !important;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
padding: 16px 32px !important;
|
|
||||||
width: 100% !important;
|
|
||||||
text-transform: uppercase !important;
|
|
||||||
letter-spacing: 0.5px !important;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
position: relative !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.2);
|
|
||||||
outline: none !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-submit-btn::before {
|
.login-footer p {
|
||||||
content: '';
|
font-size: 0.78rem;
|
||||||
position: absolute;
|
color: var(--dark-500);
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
|
||||||
transition: left 0.6s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-submit-btn:hover::before {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-submit-btn:hover {
|
|
||||||
transform: translateY(-3px) !important;
|
|
||||||
box-shadow: 0 12px 40px rgba(59, 130, 246, 0.3), 0 0 60px rgba(59, 130, 246, 0.15) !important;
|
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #059669 50%, #ea580 100%) !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-submit-btn:active {
|
|
||||||
transform: translateY(-1px) !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-submit-btn:focus {
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3), 0 12px 40px rgba(59, 130, 246, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Validation messages styling */
|
|
||||||
.text-danger {
|
|
||||||
color: rgba(239, 68, 68, 0.9) !important;
|
|
||||||
font-size: 0.85rem !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
margin-top: 6px !important;
|
|
||||||
display: block !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.validation-summary-errors {
|
.login-footer .secure-badge {
|
||||||
background: rgba(239, 68, 68, 0.1) !important;
|
display: inline-flex;
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3) !important;
|
align-items: center;
|
||||||
border-radius: 8px !important;
|
gap: 0.4rem;
|
||||||
padding: 12px 16px !important;
|
font-family: var(--font-mono);
|
||||||
margin-bottom: 24px !important;
|
font-size: 0.62rem;
|
||||||
backdrop-filter: blur(10px);
|
color: var(--dark-500);
|
||||||
}
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
.validation-summary-errors ul {
|
background: rgba(255,255,255,0.03);
|
||||||
margin: 0 !important;
|
border: 1px solid var(--glass-border);
|
||||||
padding-left: 0 !important;
|
border-radius: 50px;
|
||||||
list-style: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-summary-errors li {
|
|
||||||
color: rgba(239, 68, 68, 0.9) !important;
|
|
||||||
font-size: 0.85rem !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
margin-bottom: 4px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-summary-errors li:before {
|
|
||||||
content: '⚠ ';
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification styling */
|
|
||||||
.alert {
|
|
||||||
background: rgba(59, 130, 246, 0.1) !important;
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3) !important;
|
|
||||||
border-radius: 12px !important;
|
|
||||||
color: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@@media (max-width: 768px) {
|
|
||||||
#BannerBackground
|
|
||||||
|
|
||||||
{
|
|
||||||
padding: 20px 0;
|
|
||||||
min-height: calc(100vh - 40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
padding: 32px 24px;
|
|
||||||
border-radius: 16px;
|
|
||||||
margin: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
padding: 14px 16px !important;
|
|
||||||
font-size: 0.95rem !important;
|
|
||||||
height: 52px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-submit-btn {
|
|
||||||
padding: 14px 28px !important;
|
|
||||||
font-size: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-element {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-footer .secure-badge i {
|
||||||
|
color: var(--nex-emerald);
|
||||||
|
font-size: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
@@media (max-width: 480px) {
|
@@media (max-width: 480px) {
|
||||||
.login-card
|
#loginWrapper { padding: 1rem; }
|
||||||
|
.login-card { padding: 2rem 1.5rem; border-radius: 20px; }
|
||||||
{
|
.login-title { font-size: 1.5rem; }
|
||||||
padding: 24px 20px;
|
.orb { display: none; }
|
||||||
margin: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
padding: 12px 14px !important;
|
|
||||||
font-size: 0.9rem !important;
|
|
||||||
height: 48px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-submit-btn {
|
|
||||||
padding: 12px 24px !important;
|
|
||||||
font-size: 0.95rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="d-flex flex-column" id="BannerBackground">
|
<div id="loginWrapper">
|
||||||
<!-- Floating background elements -->
|
<!-- Background -->
|
||||||
<div class="floating-elements">
|
<div class="bg-grid"></div>
|
||||||
<div class="floating-element"></div>
|
<div class="bg-mesh"></div>
|
||||||
<div class="floating-element"></div>
|
<div class="orb orb-1"></div>
|
||||||
<div class="floating-element"></div>
|
<div class="orb orb-2"></div>
|
||||||
</div>
|
<div class="orb orb-3"></div>
|
||||||
|
|
||||||
<!-- Main login section -->
|
<!-- Login Card -->
|
||||||
<section class="text-white">
|
<div class="login-card">
|
||||||
<div class="container py-1">
|
<div class="card-glow"></div>
|
||||||
<div class="login-container">
|
|
||||||
<div class="login-card">
|
|
||||||
<!-- Login header -->
|
|
||||||
<div class="login-header">
|
|
||||||
<h1 class="login-title">Welcome Back</h1>
|
|
||||||
<p class="login-subtitle">Sign in to your account to continue</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Brand -->
|
||||||
<partial name="_Notification" />
|
<div class="login-brand">
|
||||||
|
<div class="brand-icon">
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
</div>
|
||||||
|
<div class="brand-badge"><span class="dot"></span> SECURE LOGIN</div>
|
||||||
|
<h1 class="login-title">Welcome <span class="grad">Back</span></h1>
|
||||||
|
<p class="login-sub">Sign in to access your dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Login form -->
|
<!-- Notifications -->
|
||||||
<form asp-action="Login">
|
<partial name="_Notification" />
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger validation-summary-errors"></div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Form -->
|
||||||
<label asp-for="Email" class="form-label">Email Address</label>
|
<form asp-action="Login">
|
||||||
<input asp-for="Email" type="email" class="form-control" placeholder="Enter your email address">
|
<div asp-validation-summary="ModelOnly" class="text-danger validation-summary-errors"></div>
|
||||||
<span asp-validation-for="Email" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="f-group">
|
||||||
<label asp-for="Password" class="form-label">Password</label>
|
<label class="f-label" asp-for="Email"><i class="fa-solid fa-envelope"></i> Email Address</label>
|
||||||
<input asp-for="Password" type="password" class="form-control" placeholder="Enter your password">
|
<div class="input-wrap">
|
||||||
<span asp-validation-for="Password" class="text-danger"></span>
|
<i class="fa-solid fa-at input-icon"></i>
|
||||||
</div>
|
<input asp-for="Email" type="email" class="f-input" placeholder="Enter your email" autocomplete="email" />
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="Email" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-check">
|
<div class="f-group">
|
||||||
<input class="form-check-input" asp-for="RememberMe" type="checkbox" id="rememberMe">
|
<label class="f-label" asp-for="Password"><i class="fa-solid fa-lock"></i> Password</label>
|
||||||
<label class="form-check-label" for="rememberMe">
|
<div class="input-wrap">
|
||||||
@Html.DisplayNameFor(model => model.RememberMe)
|
<i class="fa-solid fa-key input-icon"></i>
|
||||||
</label>
|
<input asp-for="Password" type="password" class="f-input" id="passwordInput" placeholder="Enter your password" autocomplete="current-password" />
|
||||||
</div>
|
<button type="button" class="pw-toggle" onclick="togglePassword()">
|
||||||
|
<i class="fa-solid fa-eye" id="pwIcon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="Password" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="remember-row">
|
||||||
<button type="submit" class="btn login-submit-btn">
|
<div class="nex-check-wrap">
|
||||||
Sign In
|
<input asp-for="RememberMe" type="checkbox" id="rememberMe" />
|
||||||
</button>
|
<label for="rememberMe">Remember me</label>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="login-btn">
|
||||||
|
<i class="fa-solid fa-right-to-bracket"></i>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="login-footer">
|
||||||
|
<div class="secure-badge">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
ENCRYPTED · SECURE CONNECTION
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@{
|
@{
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
}
|
}
|
||||||
}
|
<script>
|
||||||
|
function togglePassword() {
|
||||||
|
const input = document.getElementById('passwordInput');
|
||||||
|
const icon = document.getElementById('pwIcon');
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
icon.classList.remove('fa-eye');
|
||||||
|
icon.classList.add('fa-eye-slash');
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
icon.classList.remove('fa-eye-slash');
|
||||||
|
icon.classList.add('fa-eye');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,198 +4,617 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
/* ══════════════════════════════════════
|
||||||
|
MAIN SECTION — TWO COLUMN
|
||||||
#MainContent {
|
══════════════════════════════════════ */
|
||||||
background: #000000;
|
.main-section {
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 60vh;
|
overflow: hidden;
|
||||||
display: flex;
|
background: #030408;
|
||||||
align-items: center;
|
padding: 90px 0;
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#MainContent::before {
|
.main-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 600px 400px at 20% 40%, rgba(59, 123, 247, 0.04) 0%, transparent 100%),
|
||||||
|
radial-gradient(ellipse 500px 350px at 80% 60%, rgba(34, 201, 176, 0.03) 0%, transparent 100%);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-section .s-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.01) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.01) 1px, transparent 1px);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Two Column Grid ── */
|
||||||
|
.two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 56px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════
|
||||||
|
LEFT COLUMN — Content (no card)
|
||||||
|
══════════════════════════════════════ */
|
||||||
|
.left-col {
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 14px 5px 8px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(59, 123, 247, 0.06);
|
||||||
|
border: 1px solid rgba(59, 123, 247, 0.12);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-tag .tag-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b7bf7;
|
||||||
|
box-shadow: 0 0 6px rgba(59, 123, 247, 0.5);
|
||||||
|
animation: dotPulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes dotPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-title {
|
||||||
|
font-family: 'Outfit', 'DM Sans', sans-serif;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2.2rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-sep {
|
||||||
|
width: 40px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, rgba(59, 123, 247, 0.4), rgba(34, 201, 176, 0.3));
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-body {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #0d1a2e 0%, #080f1c 100%);
|
||||||
|
border: 1px solid rgba(59, 123, 247, 0.18);
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-cta:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 28px rgba(59, 123, 247, 0.15);
|
||||||
|
border-color: rgba(59, 123, 247, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-cta i { font-size: 0.85rem; transition: transform 0.3s ease; }
|
||||||
|
.lc-cta:hover i { transform: scale(1.15); }
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════
|
||||||
|
RIGHT COLUMN — 4 Cards (2x2)
|
||||||
|
══════════════════════════════════════ */
|
||||||
|
.right-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.why-card {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 26px 20px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.why-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
height: 2px;
|
||||||
background: radial-gradient(circle at 25% 25%, rgba(59, 130, 246, 0.08) 0%, transparent 35%), radial-gradient(circle at 75% 75%, rgba(20, 184, 166, 0.06) 0%, transparent 35%), radial-gradient(circle at 50% 50%, rgba(249, 115, 22, 0.04) 0%, transparent 40%), linear-gradient(135deg, #010203 0%, #050812 30%, #010203 70%, #000101 100%);
|
background: linear-gradient(90deg, transparent, var(--wc-accent), transparent);
|
||||||
z-index: 0;
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-pattern-main {
|
.why-card:hover::before { opacity: 1; }
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
.why-card:hover {
|
||||||
left: 0;
|
background: rgba(255, 255, 255, 0.035);
|
||||||
right: 0;
|
border-color: rgba(255, 255, 255, 0.07);
|
||||||
bottom: 0;
|
transform: translateY(-4px);
|
||||||
background-image: radial-gradient(circle at 30% 30%, rgba(59, 130, 246, 0.08) 0%, transparent 2px), radial-gradient(circle at 70% 70%, rgba(20, 184, 166, 0.06) 0%, transparent 2px), linear-gradient(90deg, rgba(255, 255, 255, 0.01) 1px, transparent 1px), linear-gradient(180deg, rgba(255, 255, 255, 0.01) 1px, transparent 1px);
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||||
background-size: 120px 120px, 150px 150px, 60px 60px, 60px 60px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container-fluid {
|
.wc--trust { --wc-accent: #3b7bf7; }
|
||||||
position: relative;
|
.wc--security { --wc-accent: #22c9b0; }
|
||||||
z-index: 10;
|
.wc--support { --wc-accent: #a78bfa; }
|
||||||
|
.wc--results { --wc-accent: #fbbf24; }
|
||||||
|
|
||||||
|
.wc-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: all 0.35s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content-wrapper {
|
.why-card:hover .wc-icon { transform: scale(1.1); }
|
||||||
background: rgba(59, 130, 246, 0.05);
|
|
||||||
backdrop-filter: blur(25px);
|
.wc--trust .wc-icon {
|
||||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
background: rgba(59, 123, 247, 0.08);
|
||||||
border-radius: 24px;
|
border: 1px solid rgba(59, 123, 247, 0.12);
|
||||||
padding: 40px 40px;
|
color: #3b7bf7;
|
||||||
text-align: center;
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
.wc--trust:hover .wc-icon { box-shadow: 0 4px 14px rgba(59, 123, 247, 0.18); }
|
||||||
|
|
||||||
.main-content-wrapper:hover {
|
.wc--security .wc-icon {
|
||||||
background: rgba(59, 130, 246, 0.08);
|
background: rgba(34, 201, 176, 0.08);
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
border: 1px solid rgba(34, 201, 176, 0.12);
|
||||||
transform: translateY(-5px);
|
color: #22c9b0;
|
||||||
box-shadow: 0 25px 50px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-title {
|
|
||||||
font-size: clamp(1.5rem, 3vw, 2.5rem);
|
|
||||||
font-weight: 900;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
.wc--security:hover .wc-icon { box-shadow: 0 4px 14px rgba(34, 201, 176, 0.18); }
|
||||||
|
|
||||||
.main-content {
|
.wc--support .wc-icon {
|
||||||
font-size: 1.2rem;
|
background: rgba(167, 139, 250, 0.08);
|
||||||
color: rgba(255, 255, 255, 0.9) !important;
|
border: 1px solid rgba(167, 139, 250, 0.12);
|
||||||
line-height: 1.7;
|
color: #a78bfa;
|
||||||
margin-bottom: 30px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
.wc--support:hover .wc-icon { box-shadow: 0 4px 14px rgba(167, 139, 250, 0.18); }
|
||||||
|
|
||||||
.btn-main {
|
.wc--results .wc-icon {
|
||||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
background: rgba(251, 191, 36, 0.08);
|
||||||
color: white !important;
|
border: 1px solid rgba(251, 191, 36, 0.12);
|
||||||
padding: 16px 40px;
|
color: #fbbf24;
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
}
|
||||||
border-radius: 12px;
|
.wc--results:hover .wc-icon { box-shadow: 0 4px 14px rgba(251, 191, 36, 0.18); }
|
||||||
|
|
||||||
|
.wc-title {
|
||||||
|
font-family: 'Outfit', 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
color: rgba(255, 255, 255, 0.9);
|
||||||
text-decoration: none;
|
margin-bottom: 7px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-desc {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transition: all 0.3s ease;
|
margin-top: 12px;
|
||||||
box-shadow: 0 8px 25px rgba(30, 41, 59, 0.4);
|
padding: 3px 9px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-main:hover {
|
.wc--trust .wc-tag { background: rgba(59, 123, 247, 0.07); color: rgba(59, 123, 247, 0.6); border: 1px solid rgba(59, 123, 247, 0.1); }
|
||||||
transform: translateY(-3px);
|
.wc--security .wc-tag { background: rgba(34, 201, 176, 0.07); color: rgba(34, 201, 176, 0.6); border: 1px solid rgba(34, 201, 176, 0.1); }
|
||||||
box-shadow: 0 12px 30px rgba(30, 41, 59, 0.6);
|
.wc--support .wc-tag { background: rgba(167, 139, 250, 0.07); color: rgba(167, 139, 250, 0.6); border: 1px solid rgba(167, 139, 250, 0.1); }
|
||||||
color: white !important;
|
.wc--results .wc-tag { background: rgba(251, 191, 36, 0.07); color: rgba(251, 191, 36, 0.6); border: 1px solid rgba(251, 191, 36, 0.1); }
|
||||||
text-decoration: none;
|
|
||||||
background: linear-gradient(135deg, #0f172a 0%, #020617 100%);
|
/* ══════════════════════════════════════
|
||||||
border-color: rgba(59, 130, 246, 0.5);
|
CONTACT MODAL
|
||||||
|
══════════════════════════════════════ */
|
||||||
|
.contact-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-overlay.active { display: flex; }
|
||||||
|
|
||||||
|
.contact-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
animation: bIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes bIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.contact-modal {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: rgba(8, 10, 22, 0.98);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6);
|
||||||
|
animation: mIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes mIn {
|
||||||
|
from { opacity: 0; transform: translateY(16px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 28px 28px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-header::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-icon-wrap {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 123, 247, 0.1), rgba(34, 201, 176, 0.08));
|
||||||
|
border: 1px solid rgba(59, 123, 247, 0.12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #3b7bf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-title {
|
||||||
|
font-family: 'Outfit', 'DM Sans', sans-serif;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-subtitle {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-body {
|
||||||
|
padding: 20px 24px 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-option:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-opt-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-option:hover .cm-opt-icon { transform: scale(1.08); }
|
||||||
|
|
||||||
|
.cm-opt-icon--phone {
|
||||||
|
background: rgba(34, 201, 176, 0.08);
|
||||||
|
border: 1px solid rgba(34, 201, 176, 0.12);
|
||||||
|
color: #22c9b0;
|
||||||
|
}
|
||||||
|
.cm-option:hover .cm-opt-icon--phone { box-shadow: 0 4px 14px rgba(34, 201, 176, 0.15); }
|
||||||
|
|
||||||
|
.cm-opt-icon--email {
|
||||||
|
background: rgba(59, 123, 247, 0.08);
|
||||||
|
border: 1px solid rgba(59, 123, 247, 0.12);
|
||||||
|
color: #3b7bf7;
|
||||||
|
}
|
||||||
|
.cm-option:hover .cm-opt-icon--email { box-shadow: 0 4px 14px rgba(59, 123, 247, 0.15); }
|
||||||
|
|
||||||
|
.cm-opt-info { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.cm-opt-label {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-opt-value {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-opt-arrow {
|
||||||
|
color: rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-option:hover .cm-opt-arrow {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════
|
||||||
|
RESPONSIVE
|
||||||
|
══════════════════════════════════════ */
|
||||||
|
@@media (max-width: 900px) {
|
||||||
|
.two-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.left-col {
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-sep { margin-left: auto; margin-right: auto; }
|
||||||
|
|
||||||
|
.right-col {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@media (max-width: 768px) {
|
@@media (max-width: 768px) {
|
||||||
#MainContent
|
.main-section { padding: 60px 0 70px; }
|
||||||
|
.main-wrap { padding: 0 20px; }
|
||||||
{
|
.lc-title { font-size: clamp(1.3rem, 5vw, 1.8rem); }
|
||||||
padding: 30px 20px;
|
.lc-body { font-size: 0.84rem; }
|
||||||
}
|
.lc-cta { padding: 11px 28px; font-size: 0.85rem; }
|
||||||
|
.why-card { padding: 22px 18px 20px; }
|
||||||
.main-content-wrapper {
|
|
||||||
padding: 30px 25px;
|
|
||||||
margin: 0 15px;
|
|
||||||
max-width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-title {
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-main {
|
|
||||||
padding: 14px 32px;
|
|
||||||
font-size: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-elements-main {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@media (max-width: 480px) {
|
|
||||||
.main-content-wrapper
|
|
||||||
|
|
||||||
{
|
|
||||||
padding: 25px 20px;
|
|
||||||
margin: 0 10px;
|
|
||||||
max-width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-title {
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-main {
|
|
||||||
padding: 12px 28px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 520px) {
|
||||||
|
.main-section { padding: 48px 0 56px; }
|
||||||
|
.main-wrap { padding: 0 14px; }
|
||||||
|
.lc-title { font-size: clamp(1.2rem, 5vw, 1.5rem); }
|
||||||
|
.lc-cta { justify-content: center; }
|
||||||
|
.right-col { grid-template-columns: 1fr; max-width: 360px; gap: 10px; }
|
||||||
|
.contact-modal { max-width: calc(100% - 32px); border-radius: 16px; }
|
||||||
|
.cm-header { padding: 24px 20px 18px; }
|
||||||
|
.cm-body { padding: 16px 18px 24px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<section id="MainContent" class="text-white">
|
<section class="main-section">
|
||||||
<div class="tech-pattern-main"></div>
|
<div class="s-grid"></div>
|
||||||
|
|
||||||
<div class="floating-elements-main">
|
<div class="main-wrap">
|
||||||
<div class="floating-element-main"></div>
|
<div class="two-col">
|
||||||
<div class="floating-element-main"></div>
|
<!-- ── Left Column — Content ── -->
|
||||||
<div class="floating-element-main"></div>
|
<div class="left-col">
|
||||||
</div>
|
<div class="lc-tag">
|
||||||
|
<span class="tag-dot"></span>
|
||||||
|
About Us
|
||||||
|
</div>
|
||||||
|
<h2 class="lc-title">@Model.Title.ToUpper()</h2>
|
||||||
|
<div class="lc-sep"></div>
|
||||||
|
<div class="lc-body">@Html.Raw(Model.Content)</div>
|
||||||
|
<button type="button" class="lc-cta" id="contactOpen">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container-fluid py-1">
|
<!-- ── Right Column — 4 Cards (2x2) ── -->
|
||||||
<div class="main-content-wrapper">
|
<div class="right-col">
|
||||||
<h4 class="main-title">@Model.Title.ToUpper()</h4>
|
<div class="why-card wc--trust">
|
||||||
<p class="main-content">@Html.Raw(Model.Content)</p>
|
<div class="wc-icon"><i class="bi bi-building-check"></i></div>
|
||||||
<a href="#" class="btn-main">Contact</a>
|
<div class="wc-title">Trusted by Organizations</div>
|
||||||
|
<div class="wc-desc">Serving 25+ Danish organizations with reliable mental health intelligence.</div>
|
||||||
|
<span class="wc-tag">25+ Clients</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="why-card wc--security">
|
||||||
|
<div class="wc-icon"><i class="bi bi-shield-lock"></i></div>
|
||||||
|
<div class="wc-title">Enterprise Security</div>
|
||||||
|
<div class="wc-desc">GDPR-compliant with end-to-end encryption. Your data stays private.</div>
|
||||||
|
<span class="wc-tag">GDPR Ready</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="why-card wc--support">
|
||||||
|
<div class="wc-icon"><i class="bi bi-headset"></i></div>
|
||||||
|
<div class="wc-title">Dedicated Support</div>
|
||||||
|
<div class="wc-desc">Personalized onboarding and ongoing Danish-language support.</div>
|
||||||
|
<span class="wc-tag">Danish Support</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="why-card wc--results">
|
||||||
|
<div class="wc-icon"><i class="bi bi-lightning-charge"></i></div>
|
||||||
|
<div class="wc-title">Proven Results</div>
|
||||||
|
<div class="wc-desc">AI-powered analysis for measurable wellbeing improvements.</div>
|
||||||
|
<span class="wc-tag">AI Powered</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════
|
||||||
|
CONTACT MODAL
|
||||||
|
══════════════════════════════════════ -->
|
||||||
|
<div class="contact-overlay" id="contactOverlay">
|
||||||
|
<div class="contact-backdrop" id="contactBackdrop"></div>
|
||||||
|
<div class="contact-modal">
|
||||||
|
<div class="cm-header">
|
||||||
|
<button class="cm-close" id="contactClose"><i class="bi bi-x-lg"></i></button>
|
||||||
|
<div class="cm-icon-wrap"><i class="bi bi-chat-dots"></i></div>
|
||||||
|
<div class="cm-title">Get in Touch</div>
|
||||||
|
<div class="cm-subtitle">Choose how you'd like to reach us</div>
|
||||||
|
</div>
|
||||||
|
<div class="cm-body">
|
||||||
|
<a href="tel:+4561777336" class="cm-option">
|
||||||
|
<div class="cm-opt-icon cm-opt-icon--phone"><i class="bi bi-telephone"></i></div>
|
||||||
|
<div class="cm-opt-info">
|
||||||
|
<div class="cm-opt-label">Call us</div>
|
||||||
|
<div class="cm-opt-value">+45 61 777 336</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-chevron-right cm-opt-arrow"></i>
|
||||||
|
</a>
|
||||||
|
<a href="mailto:info@seosoft.dk" class="cm-option">
|
||||||
|
<div class="cm-opt-icon cm-opt-icon--email"><i class="bi bi-envelope"></i></div>
|
||||||
|
<div class="cm-opt-info">
|
||||||
|
<div class="cm-opt-label">Email us</div>
|
||||||
|
<div class="cm-opt-value">info@seosoft.dk</div>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-chevron-right cm-opt-arrow"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const overlay = document.getElementById('contactOverlay');
|
||||||
|
const openBtn = document.getElementById('contactOpen');
|
||||||
|
const closeBtn = document.getElementById('contactClose');
|
||||||
|
const backdrop = document.getElementById('contactBackdrop');
|
||||||
|
|
||||||
|
function openModal() { overlay.classList.add('active'); document.body.style.overflow = 'hidden'; }
|
||||||
|
function closeModal() { overlay.classList.remove('active'); document.body.style.overflow = ''; }
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', openModal);
|
||||||
|
closeBtn.addEventListener('click', closeModal);
|
||||||
|
backdrop.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && overlay.classList.contains('active')) closeModal();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@{
|
@{
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
77
Web/Views/Shared/_AccessDeniedModal.cshtml
Normal file
77
Web/Views/Shared/_AccessDeniedModal.cshtml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<!-- Access Denied Modal — shared across all admin pages -->
|
||||||
|
<div class="modal-overlay" id="accessDeniedModal" style="display:none;position:fixed;inset:0;z-index:99999;background:rgba(2,6,23,0.8);backdrop-filter:blur(8px);align-items:center;justify-content:center;padding:2rem;">
|
||||||
|
<div style="background:#1e293b;border:1px solid rgba(255,255,255,0.08);border-radius:20px;width:100%;max-width:460px;position:relative;animation:adSlideUp .3s ease;overflow:hidden;">
|
||||||
|
<div style="position:absolute;top:0;left:0;right:0;height:3px;border-radius:20px 20px 0 0;background:linear-gradient(90deg,#f87171,#dc2626);"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:1.5rem 1.75rem 1rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.85rem;">
|
||||||
|
<div style="width:42px;height:42px;border-radius:12px;background:rgba(248,113,113,0.15);display:flex;align-items:center;justify-content:center;font-size:1.1rem;color:#f87171;">
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size:1.1rem;font-weight:700;color:#f8fafc;margin:0;">Access Denied</h3>
|
||||||
|
<p style="font-size:0.78rem;color:#94a3b8;margin:2px 0 0;">Insufficient permissions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="closeAccessDeniedModal()" style="width:36px;height:36px;border-radius:10px;border:1px solid rgba(255,255,255,0.08);background:none;color:#94a3b8;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:0.9rem;">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;padding:1rem 1.75rem 2rem;">
|
||||||
|
<div style="width:70px;height:70px;border-radius:16px;background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.2);display:flex;align-items:center;justify-content:center;margin:0 auto 1.25rem;font-size:1.8rem;color:#f87171;animation:adShake .6s ease-in-out;">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
<h4 style="font-size:1.15rem;font-weight:700;color:#f8fafc;margin:0 0 0.5rem;">You Don't Have Permission</h4>
|
||||||
|
<p style="font-size:0.85rem;color:#94a3b8;line-height:1.7;margin:0 0 1.5rem;">
|
||||||
|
Your current role does not include the required privileges for this action.
|
||||||
|
Please contact your <span style="color:#f87171;font-weight:600;">system administrator</span> to request access.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;background:rgba(248,113,113,0.08);border:1px solid rgba(248,113,113,0.15);border-radius:10px;margin:0 0 1.5rem;text-align:left;">
|
||||||
|
<i class="fa-solid fa-circle-info" style="color:#f87171;flex-shrink:0;"></i>
|
||||||
|
<span style="font-size:0.78rem;color:#cbd5e1;line-height:1.5;">
|
||||||
|
Logged in as: <strong style="color:#f8fafc;">@User.Identity?.Name</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="closeAccessDeniedModal()" style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.65rem 1.5rem;border-radius:10px;font-family:'Space Grotesk',sans-serif;font-size:0.85rem;font-weight:600;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.05);color:#e2e8f0;cursor:pointer;">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i> Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@@keyframes adSlideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
@@keyframes adShake { 0%,100%{transform:translateX(0)} 15%{transform:translateX(-8px)} 30%{transform:translateX(8px)} 45%{transform:translateX(-6px)} 60%{transform:translateX(6px)} 75%{transform:translateX(-3px)} 90%{transform:translateX(3px)} }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openAccessDeniedModal() {
|
||||||
|
var m = document.getElementById('accessDeniedModal');
|
||||||
|
m.style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function closeAccessDeniedModal() {
|
||||||
|
var m = document.getElementById('accessDeniedModal');
|
||||||
|
m.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
document.getElementById('accessDeniedModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeAccessDeniedModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('accessDeniedModal').style.display === 'flex') {
|
||||||
|
closeAccessDeniedModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Auto-show if redirected with ?accessDenied=true
|
||||||
|
(function() {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('accessDenied') === 'true') {
|
||||||
|
openAccessDeniedModal();
|
||||||
|
// Clean URL without reloading
|
||||||
|
var cleanUrl = window.location.pathname + window.location.search.replace(/[?&]accessDenied=true/, '').replace(/^\?$/, '');
|
||||||
|
window.history.replaceState({}, '', cleanUrl);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -36,6 +36,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="NewFolder\" />
|
<Folder Include="NewFolder\" />
|
||||||
|
<Folder Include="wwwroot\uploads\questionimages\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -7988,7 +7988,7 @@ body, html {
|
||||||
.d-flex {
|
.d-flex {
|
||||||
display: -webkit-box !important;
|
display: -webkit-box !important;
|
||||||
display: -ms-flexbox !important;
|
display: -ms-flexbox !important;
|
||||||
display: flex !important;
|
display:block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.d-inline-flex {
|
.d-inline-flex {
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Loading…
Add table
Reference in a new issue