diff --git a/Model/Question.cs b/Model/Question.cs index 9a82dc1..a16a279 100644 --- a/Model/Question.cs +++ b/Model/Question.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel; using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Model { @@ -27,6 +21,9 @@ namespace Model [ForeignKey("QuestionnaireId")] public Questionnaire? Questionnaire { get; set; } - public List Answers { get; set; } + public List Answers { get; set; } + + public bool IsActive { get; set; } = true; // Default to active + public DateTime CreatedDate { get; set; } = DateTime.UtcNow; // Default to now } } diff --git a/Model/Questionnaire.cs b/Model/Questionnaire.cs index 38caed3..b707782 100644 --- a/Model/Questionnaire.cs +++ b/Model/Questionnaire.cs @@ -6,15 +6,28 @@ using System.Threading.Tasks; namespace Model { + public class Questionnaire { public Questionnaire() { Questions = new List(); + + // ADD THESE NEW LINES (with safe defaults): + Status = QuestionnaireStatus.Draft; // Default to Draft + CreatedDate = DateTime.UtcNow; // Default to now } + + // EXISTING PROPERTIES (keep as-is): public int Id { get; set; } public string? Title { get; set; } public string? Description { get; set; } public List? Questions { get; set; } + + // ADD THESE NEW PROPERTIES: + public QuestionnaireStatus Status { get; set; } = QuestionnaireStatus.Draft; + public DateTime CreatedDate { get; set; } = DateTime.UtcNow; + public DateTime? PublishedDate { get; set; } // Nullable - only set when published + public DateTime? ArchivedDate { get; set; } // Nullable - only set when archived } } diff --git a/Model/QuestionnaireStatus.cs b/Model/QuestionnaireStatus.cs new file mode 100644 index 0000000..eac3c87 --- /dev/null +++ b/Model/QuestionnaireStatus.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Model +{ + public enum QuestionnaireStatus + { + Draft = 0, // Can be fully edited/deleted + Published = 1, // Live, accepting responses - limited editing + Archived = 2 // Completed, read-only + } +} diff --git a/Services/Implemnetation/QuestionnaireRepository.cs b/Services/Implemnetation/QuestionnaireRepository.cs index c27b9ff..3c330e8 100644 --- a/Services/Implemnetation/QuestionnaireRepository.cs +++ b/Services/Implemnetation/QuestionnaireRepository.cs @@ -13,72 +13,129 @@ namespace Services.Implemnetation { _context = Context; } + + // EXISTING METHOD - Keep exactly as is public void Add(Questionnaire questionnaire) { _context.Questionnaires.Add(questionnaire); } + // EXISTING METHOD - Keep exactly as is public async Task commitAsync() { await _context.SaveChangesAsync(); } - //public void Delete(int? id) - //{ - // var questionnairId = GetQuesById(id); - - // _context.Questionnaires.Remove(questionnairId); - //} - + // EXISTING METHOD - Keep exactly as is public List GetAllQuestions() { return _context.Questionnaires.ToList(); } + // EXISTING METHOD - Keep exactly as is public Questionnaire GetQuesById(int? id) { return _context.Questionnaires.Find(id); } + // UPDATE THIS METHOD - Add filter for active questions only public List GetQuestionnairesWithQuestion() { - return _context.Questionnaires.AsNoTracking().Include(x=>x.Questions).ThenInclude(x=>x.Answers).ToList(); + return _context.Questionnaires + .AsNoTracking() + .Include(x => x.Questions.Where(q => q.IsActive)) // Only get active questions + .ThenInclude(x => x.Answers) + .ToList(); } + // UPDATE THIS METHOD - Add filter for active questions only public Questionnaire GetQuestionnaireWithQuestionAndAnswer(int? id) { - return _context.Questionnaires // ✅ No AsNoTracking for edit operations! - .Include(x => x.Questions) + return _context.Questionnaires + .Include(x => x.Questions.Where(q => q.IsActive)) // Only get active questions .ThenInclude(x => x.Answers) .FirstOrDefault(x => x.Id == id); } + // EXISTING METHOD - Keep exactly as is public async Task Update(Questionnaire questionnaire) { - _context.Questionnaires.Update(questionnaire); - - await _context.SaveChangesAsync(); - + await _context.SaveChangesAsync(); } + // EXISTING METHOD - Keep exactly as is public async Task Delete(int? id) { if (id == null) { throw new ArgumentNullException(nameof(id), "ID cannot be null"); } - var questionnaire = GetQuesById(id); - if (questionnaire == null) { throw new ArgumentException("Questionnaire not found", nameof(id)); } - _context.Questionnaires.Remove(questionnaire); await _context.SaveChangesAsync(); } + // ADD THESE NEW METHODS (for future status management): + + // Get questionnaires by status + public List GetQuestionnairesByStatus(QuestionnaireStatus status) + { + return _context.Questionnaires + .Where(q => q.Status == status) + .Include(x => x.Questions.Where(q => q.IsActive)) + .ThenInclude(x => x.Answers) + .ToList(); + } + + // Get all questionnaires with status info (for admin dashboard) + public List GetAllQuestionnairesWithStatus() + { + return _context.Questionnaires + .Include(x => x.Questions.Where(q => q.IsActive)) + .ThenInclude(x => x.Answers) + .OrderByDescending(q => q.CreatedDate) + .ToList(); + } + + // Check if questionnaire has responses (useful for status changes) + public async Task HasResponses(int questionnaireId) + { + return await _context.Responses + .AnyAsync(r => r.QuestionnaireId == questionnaireId); + } + + // Update questionnaire status + public async Task UpdateStatus(int questionnaireId, QuestionnaireStatus newStatus) + { + var questionnaire = await _context.Questionnaires.FindAsync(questionnaireId); + if (questionnaire != null) + { + questionnaire.Status = newStatus; + + // Set appropriate timestamps + switch (newStatus) + { + case QuestionnaireStatus.Published: + if (questionnaire.PublishedDate == null) + questionnaire.PublishedDate = DateTime.UtcNow; + break; + case QuestionnaireStatus.Archived: + if (questionnaire.ArchivedDate == null) + questionnaire.ArchivedDate = DateTime.UtcNow; + break; + case QuestionnaireStatus.Draft: + questionnaire.PublishedDate = null; + questionnaire.ArchivedDate = null; + break; + } + + await _context.SaveChangesAsync(); + } + } } } diff --git a/Services/Interaces/IQuestionnaireRepository.cs b/Services/Interaces/IQuestionnaireRepository.cs index 3f2ff29..0601393 100644 --- a/Services/Interaces/IQuestionnaireRepository.cs +++ b/Services/Interaces/IQuestionnaireRepository.cs @@ -1,4 +1,5 @@ -using Model; +using Microsoft.EntityFrameworkCore.Migrations; +using Model; using System; using System.Collections.Generic; using System.Linq; @@ -19,5 +20,13 @@ namespace Services.Interaces Task Delete(int? id); Task commitAsync(); + + // ADD THESE NEW METHOD SIGNATURES: + List GetQuestionnairesByStatus(QuestionnaireStatus status); + List GetAllQuestionnairesWithStatus(); + Task HasResponses(int questionnaireId); + Task UpdateStatus(int questionnaireId, QuestionnaireStatus newStatus); } + + } diff --git a/Web/Areas/Admin/Controllers/QuestionnaireController.cs b/Web/Areas/Admin/Controllers/QuestionnaireController.cs index b1a8afc..3d06988 100644 --- a/Web/Areas/Admin/Controllers/QuestionnaireController.cs +++ b/Web/Areas/Admin/Controllers/QuestionnaireController.cs @@ -39,34 +39,63 @@ namespace Web.Areas.Admin.Controllers _configuration = configuration; _emailServices = emailServices; } - public IActionResult Index() + public async Task Index() { - - var questionnaire = _questionnaire.GetQuestionnairesWithQuestion(); - - var question = _question.GetQuestionsWithAnswers(); - - + var questionnaire = _questionnaire.GetAllQuestionnairesWithStatus(); // Use new method + var question = _question.GetQuestionsWithAnswers(); // Keep your existing line List viewmodel = new List(); - foreach (var item in questionnaire) { + // Check if this questionnaire has responses + var hasResponses = await _questionnaire.HasResponses(item.Id); + viewmodel.Add(new QuestionnaireViewModel { + // EXISTING MAPPING (keep exactly as-is): Id = item.Id, Description = item.Description, Title = item.Title, Questions = item.Questions, - - + // ADD NEW STATUS MAPPING: + Status = item.Status, + CreatedDate = item.CreatedDate, + PublishedDate = item.PublishedDate, + ArchivedDate = item.ArchivedDate, + HasResponses = hasResponses }); } return View(viewmodel); } + + [HttpGet] + public IActionResult StatusGuide() + { + // Simple documentation page - no model needed + return View(); + } + // Add this to your controller + public async Task GetQuestionnaireStats(int id) + { + var responseCount = await _context.Responses + .CountAsync(r => r.QuestionnaireId == id); + + var questionCount = await _context.Questions + .CountAsync(q => q.QuestionnaireId == id && q.IsActive); + + var questionnaire = await _context.Questionnaires.FindAsync(id); + + return Json(new + { + responseCount, + questionCount, + status = questionnaire?.Status.ToString(), + hasResponses = responseCount > 0 + }); + } [HttpGet] public IActionResult Create() @@ -143,27 +172,28 @@ namespace Web.Areas.Admin.Controllers } [HttpGet] - public IActionResult Edit(int? id) + public IActionResult Edit(int id) { var questionTypes = Enum.GetValues(typeof(QuestionType)) - .Cast() - .Select(e => new SelectListItem { Value = e.ToString(), Text = e.ToString() }); + .Cast() + .Select(e => new SelectListItem { Value = e.ToString(), Text = e.ToString() }); ViewBag.QuestionTypes = questionTypes; var questionnaire = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(id); if (questionnaire == null) { - return NotFound(); // Or handle not found case appropriately + return NotFound(); } + // ADD THIS LINE: Pass questionnaire to view for status checking + ViewBag.Questionnaire = questionnaire; + var viewModel = new EditQuestionnaireViewModel { Id = questionnaire.Id, Title = questionnaire.Title, Description = questionnaire.Description, - - Questions = questionnaire.Questions .Select(q => new Question { @@ -171,18 +201,13 @@ namespace Web.Areas.Admin.Controllers Text = q.Text, Type = q.Type, QuestionnaireId = q.QuestionnaireId, - - Answers = q.Answers.Select(a => new Answer { Id = a.Id, Text = a.Text, Question = a.Question, - QuestionId = a.QuestionId - - - - + QuestionId = a.QuestionId, + IsOtherOption = a.IsOtherOption }).ToList() }).ToList() }; @@ -193,9 +218,13 @@ namespace Web.Areas.Admin.Controllers [HttpPost] public async Task Edit(EditQuestionnaireViewModel viewModel) { + Console.WriteLine("=== STATUS-AWARE EDIT POST METHOD CALLED ==="); + Console.WriteLine($"Questionnaire ID: {viewModel?.Id}"); + Console.WriteLine($"Questions count: {viewModel?.Questions?.Count ?? 0}"); + var questionTypes = Enum.GetValues(typeof(QuestionType)) - .Cast() - .Select(e => new SelectListItem { Value = e.ToString(), Text = e.ToString() }); + .Cast() + .Select(e => new SelectListItem { Value = e.ToString(), Text = e.ToString() }); ViewBag.QuestionTypes = questionTypes; if (ModelState.IsValid) @@ -203,10 +232,11 @@ namespace Web.Areas.Admin.Controllers try { using var transaction = await _context.Database.BeginTransactionAsync(); + Console.WriteLine("Database transaction started"); try { - // Step 1: Update the questionnaire basic info + // Step 1: Get the questionnaire with its current status var existingQuestionnaire = await _context.Questionnaires .FirstOrDefaultAsync(q => q.Id == viewModel.Id); @@ -215,115 +245,270 @@ namespace Web.Areas.Admin.Controllers return NotFound(); } + Console.WriteLine($"Questionnaire Status: {existingQuestionnaire.Status}"); + + // Step 2: Check if questionnaire can be edited + if (existingQuestionnaire.Status == QuestionnaireStatus.Archived) + { + TempData["Error"] = "Archived questionnaires cannot be edited. Please revert to draft status first."; + await transaction.RollbackAsync(); + return RedirectToAction(nameof(Index)); + } + + // Step 3: Update basic questionnaire info (always allowed) existingQuestionnaire.Title = viewModel.Title; existingQuestionnaire.Description = viewModel.Description; - // Step 2: Get all existing questions for this questionnaire + // Step 4: Get existing questions var existingQuestions = await _context.Questions - .Where(q => q.QuestionnaireId == viewModel.Id) + .Include(q => q.Answers) + .Where(q => q.QuestionnaireId == viewModel.Id && q.IsActive) .ToListAsync(); - // Step 3: Delete ALL answers first (foreign key constraint) - if (existingQuestions.Any()) + Console.WriteLine($"Found {existingQuestions.Count} existing active questions"); + + // Step 5: Handle editing based on questionnaire status + switch (existingQuestionnaire.Status) { - var questionIds = existingQuestions.Select(q => q.Id).ToList(); - var existingAnswers = await _context.Answers - .Where(a => questionIds.Contains(a.QuestionId)) - .ToListAsync(); + case QuestionnaireStatus.Draft: + Console.WriteLine("DRAFT MODE: Full editing allowed"); + await HandleDraftQuestionnaire(viewModel, existingQuestions, existingQuestionnaire.Id); + break; - _context.Answers.RemoveRange(existingAnswers); - await _context.SaveChangesAsync(); - } - - // Step 4: Delete ALL questions - _context.Questions.RemoveRange(existingQuestions); - await _context.SaveChangesAsync(); - - // Step 5: Add new questions (only if provided and valid) - int newQuestionsAdded = 0; - if (viewModel.Questions != null && viewModel.Questions.Count > 0) - { - var validQuestions = viewModel.Questions - .Where(q => !string.IsNullOrWhiteSpace(q.Text)) - .ToList(); - - foreach (var questionViewModel in validQuestions) - { - var newQuestion = new Question - { - Text = questionViewModel.Text.Trim(), - Type = questionViewModel.Type, - QuestionnaireId = viewModel.Id - }; - - _context.Questions.Add(newQuestion); - await _context.SaveChangesAsync(); // Save to get the ID - - // Add answers for this question - if (questionViewModel.Answers != null) - { - var validAnswers = questionViewModel.Answers - .Where(a => !string.IsNullOrWhiteSpace(a.Text)) - .ToList(); - - foreach (var answerViewModel in validAnswers) - { - var newAnswer = new Answer - { - Text = answerViewModel.Text.Trim(), - IsOtherOption = answerViewModel.IsOtherOption, - QuestionId = newQuestion.Id - }; - - _context.Answers.Add(newAnswer); - } - - if (validAnswers.Any()) - { - await _context.SaveChangesAsync(); - } - } - - newQuestionsAdded++; - } + case QuestionnaireStatus.Published: + Console.WriteLine("PUBLISHED MODE: Limited editing (preserves response data)"); + await HandlePublishedQuestionnaire(viewModel, existingQuestions, existingQuestionnaire.Id); + break; } // Step 6: Final save and commit await _context.SaveChangesAsync(); await transaction.CommitAsync(); + Console.WriteLine("Transaction committed successfully"); - // Step 7: Get final count for success message + // Step 7: Success message var finalQuestionCount = await _context.Questions - .Where(q => q.QuestionnaireId == viewModel.Id) - .CountAsync(); + .CountAsync(q => q.QuestionnaireId == viewModel.Id && q.IsActive); - // Success message - if (finalQuestionCount == 0) + TempData["Success"] = $"Questionnaire updated successfully with {finalQuestionCount} question(s)!"; + + if (existingQuestionnaire.Status == QuestionnaireStatus.Published) { - TempData["Success"] = "Questionnaire updated successfully. All questions have been removed."; - } - else - { - TempData["Success"] = $"Questionnaire updated successfully with {finalQuestionCount} question(s)."; + TempData["Info"] = "Limited editing was applied to preserve response data integrity."; } return RedirectToAction(nameof(Index)); } - catch (Exception) + catch (Exception ex) { + Console.WriteLine($"ERROR in transaction: {ex.Message}"); await transaction.RollbackAsync(); throw; } } catch (Exception ex) { - ModelState.AddModelError("", "An error occurred while updating the questionnaire. Please try again."); + Console.WriteLine($"ERROR in Edit method: {ex.Message}"); + ModelState.AddModelError("", $"An error occurred while updating the questionnaire: {ex.Message}"); return View(viewModel); } } + else + { + Console.WriteLine("ModelState is NOT valid"); + foreach (var error in ModelState.Values.SelectMany(v => v.Errors)) + { + Console.WriteLine($"Validation error: {error.ErrorMessage}"); + } + } return View(viewModel); } + + // Handle Draft Questionnaires (Full Editing Allowed) + private async Task HandleDraftQuestionnaire(EditQuestionnaireViewModel viewModel, + List existingQuestions, int questionnaireId) + { + Console.WriteLine("Processing DRAFT questionnaire - full editing allowed"); + + var incomingQuestionIds = viewModel.Questions? + .Where(q => q.Id > 0 && !string.IsNullOrWhiteSpace(q.Text)) + .Select(q => q.Id) + .ToList() ?? new List(); + + // HARD DELETE is safe for draft questionnaires (no responses exist) + var questionsToDelete = existingQuestions + .Where(eq => !incomingQuestionIds.Contains(eq.Id)) + .ToList(); + + if (questionsToDelete.Any()) + { + Console.WriteLine($"Hard deleting {questionsToDelete.Count} questions from draft"); + + // Delete answers first + var questionIdsToDelete = questionsToDelete.Select(q => q.Id).ToList(); + var answersToDelete = await _context.Answers + .Where(a => questionIdsToDelete.Contains(a.QuestionId)) + .ToListAsync(); + + _context.Answers.RemoveRange(answersToDelete); + await _context.SaveChangesAsync(); + + // Delete questions + _context.Questions.RemoveRange(questionsToDelete); + await _context.SaveChangesAsync(); + } + + // Process remaining questions (full editing allowed) + await ProcessQuestions(viewModel, existingQuestions, questionnaireId, allowFullEditing: true); + } + + // Handle Published Questionnaires (Limited Editing) + private async Task HandlePublishedQuestionnaire(EditQuestionnaireViewModel viewModel, + List existingQuestions, int questionnaireId) + { + Console.WriteLine("Processing PUBLISHED questionnaire - limited editing"); + + var incomingQuestionIds = viewModel.Questions? + .Where(q => q.Id > 0 && !string.IsNullOrWhiteSpace(q.Text)) + .Select(q => q.Id) + .ToList() ?? new List(); + + // SOFT DELETE for published questionnaires (preserve response relationships) + var questionsToSoftDelete = existingQuestions + .Where(eq => !incomingQuestionIds.Contains(eq.Id)) + .ToList(); + + foreach (var question in questionsToSoftDelete) + { + question.IsActive = false; + Console.WriteLine($"Soft deleted question: {question.Text}"); + } + + // Process remaining questions (limited editing) + await ProcessQuestions(viewModel, existingQuestions, questionnaireId, allowFullEditing: false); + } + + // Common Question Processing Logic + private async Task ProcessQuestions(EditQuestionnaireViewModel viewModel, + List existingQuestions, int questionnaireId, bool allowFullEditing) + { + if (viewModel.Questions == null) return; + + var validQuestions = viewModel.Questions + .Where(q => !string.IsNullOrWhiteSpace(q.Text)) + .ToList(); + + Console.WriteLine($"Processing {validQuestions.Count} valid questions"); + + foreach (var questionViewModel in validQuestions) + { + if (questionViewModel.Id > 0) + { + // UPDATE existing question + var existingQuestion = existingQuestions.FirstOrDefault(eq => eq.Id == questionViewModel.Id && eq.IsActive); + if (existingQuestion != null) + { + Console.WriteLine($"Updating question {existingQuestion.Id}: '{questionViewModel.Text}'"); + + // Always allow text and type updates + existingQuestion.Text = questionViewModel.Text.Trim(); + existingQuestion.Type = questionViewModel.Type; + + // Answer editing depends on questionnaire status and response data + if (allowFullEditing) + { + // Full answer editing (Draft mode) + Console.WriteLine($"Full answer editing for question {existingQuestion.Id}"); + await UpdateQuestionAnswers(existingQuestion, questionViewModel); + } + else + { + // Limited answer editing (Published mode) - only if no responses + var hasResponses = await _context.ResponseDetails + .AnyAsync(rd => rd.QuestionId == existingQuestion.Id); + + if (!hasResponses) + { + Console.WriteLine($"No responses found - updating answers for question {existingQuestion.Id}"); + await UpdateQuestionAnswers(existingQuestion, questionViewModel); + } + else + { + Console.WriteLine($"Question {existingQuestion.Id} has responses - preserving answers"); + TempData["Warning"] = "Some questions have responses, so their answer options were preserved to maintain data integrity."; + } + } + } + } + else + { + // CREATE new question (always allowed) + Console.WriteLine($"Creating new question: '{questionViewModel.Text}'"); + + var newQuestion = new Question + { + Text = questionViewModel.Text.Trim(), + Type = questionViewModel.Type, + QuestionnaireId = questionnaireId, + IsActive = true, + CreatedDate = DateTime.UtcNow + }; + + _context.Questions.Add(newQuestion); + await _context.SaveChangesAsync(); // Save to get the ID + + Console.WriteLine($"New question created with ID: {newQuestion.Id}"); + + // Add answers for the new question + await AddAnswersToQuestion(newQuestion, questionViewModel); + } + } + } + + // Helper: Update Question Answers + private async Task UpdateQuestionAnswers(Question question, Question questionViewModel) + { + // Remove existing answers + var existingAnswers = question.Answers.ToList(); + if (existingAnswers.Any()) + { + _context.Answers.RemoveRange(existingAnswers); + await _context.SaveChangesAsync(); + } + + // Add new answers + await AddAnswersToQuestion(question, questionViewModel); + } + + // Helper: Add Answers to Question + private async Task AddAnswersToQuestion(Question question, Question questionViewModel) + { + if (questionViewModel.Answers != null && questionViewModel.Answers.Any()) + { + var validAnswers = questionViewModel.Answers + .Where(a => !string.IsNullOrWhiteSpace(a.Text)) + .ToList(); + + Console.WriteLine($"Adding {validAnswers.Count} answers to question {question.Id}"); + + foreach (var answerViewModel in validAnswers) + { + var newAnswer = new Answer + { + Text = answerViewModel.Text.Trim(), + IsOtherOption = answerViewModel.IsOtherOption, + QuestionId = question.Id + }; + _context.Answers.Add(newAnswer); + } + + if (validAnswers.Any()) + { + await _context.SaveChangesAsync(); + } + } + } [HttpGet] public IActionResult Delete(int id) { @@ -912,6 +1097,161 @@ namespace Web.Areas.Admin.Controllers } } + + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task PublishQuestionnaire(int id) + { + try + { + var questionnaire = await _context.Questionnaires.FindAsync(id); + if (questionnaire == null) + { + TempData["Error"] = "Questionnaire not found."; + return RedirectToAction(nameof(Index)); + } + + if (questionnaire.Status != QuestionnaireStatus.Draft) + { + TempData["Error"] = "Only draft questionnaires can be published."; + return RedirectToAction(nameof(Index)); + } + + // Check if questionnaire has questions + var hasQuestions = await _context.Questions + .AnyAsync(q => q.QuestionnaireId == id && q.IsActive); + + if (!hasQuestions) + { + TempData["Error"] = "Cannot publish questionnaire without questions."; + return RedirectToAction(nameof(Index)); + } + + await _questionnaire.UpdateStatus(id, QuestionnaireStatus.Published); + TempData["Success"] = $"Questionnaire '{questionnaire.Title}' has been published successfully!"; + } + catch (Exception ex) + { + Console.WriteLine($"Error publishing questionnaire {id}: {ex.Message}"); + TempData["Error"] = "An error occurred while publishing the questionnaire."; + } + + return RedirectToAction(nameof(Index)); + } + + // ================================================== + // NEW METHOD 2: Archive Questionnaire + // ================================================== + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ArchiveQuestionnaire(int id) + { + try + { + var questionnaire = await _context.Questionnaires.FindAsync(id); + if (questionnaire == null) + { + TempData["Error"] = "Questionnaire not found."; + return RedirectToAction(nameof(Index)); + } + + if (questionnaire.Status != QuestionnaireStatus.Published) + { + TempData["Error"] = "Only published questionnaires can be archived."; + return RedirectToAction(nameof(Index)); + } + + await _questionnaire.UpdateStatus(id, QuestionnaireStatus.Archived); + TempData["Success"] = $"Questionnaire '{questionnaire.Title}' has been archived successfully."; + } + catch (Exception ex) + { + Console.WriteLine($"Error archiving questionnaire {id}: {ex.Message}"); + TempData["Error"] = "An error occurred while archiving the questionnaire."; + } + + return RedirectToAction(nameof(Index)); + } + + // ================================================== + // NEW METHOD 3: Revert to Draft + // ================================================== + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RevertToDraft(int id) + { + try + { + var questionnaire = await _context.Questionnaires.FindAsync(id); + if (questionnaire == null) + { + TempData["Error"] = "Questionnaire not found."; + return RedirectToAction(nameof(Index)); + } + + if (questionnaire.Status == QuestionnaireStatus.Draft) + { + TempData["Warning"] = "Questionnaire is already in draft status."; + return RedirectToAction(nameof(Index)); + } + + var hasResponses = await _questionnaire.HasResponses(id); + if (hasResponses) + { + TempData["Error"] = "Cannot revert questionnaire to draft status because it has survey responses."; + return RedirectToAction(nameof(Index)); + } + + await _questionnaire.UpdateStatus(id, QuestionnaireStatus.Draft); + TempData["Success"] = $"Questionnaire '{questionnaire.Title}' has been reverted to draft status."; + } + catch (Exception ex) + { + Console.WriteLine($"Error reverting questionnaire {id}: {ex.Message}"); + TempData["Error"] = "An error occurred while reverting the questionnaire."; + } + + return RedirectToAction(nameof(Index)); + } + + // ================================================== + // NEW METHOD 4: Get Status Info (Helper for Views) + // ================================================== + [HttpGet] + public async Task GetQuestionnaireStatus(int id) + { + try + { + var questionnaire = await _context.Questionnaires.FindAsync(id); + if (questionnaire == null) + { + return Json(new { success = false, message = "Questionnaire not found" }); + } + + var hasResponses = await _questionnaire.HasResponses(id); + var questionCount = await _context.Questions + .CountAsync(q => q.QuestionnaireId == id && q.IsActive); + + return Json(new + { + success = true, + status = questionnaire.Status.ToString(), + hasResponses = hasResponses, + questionCount = questionCount, + createdDate = questionnaire.CreatedDate, + publishedDate = questionnaire.PublishedDate, + archivedDate = questionnaire.ArchivedDate + }); + } + catch (Exception ex) + { + Console.WriteLine($"Error getting status for questionnaire {id}: {ex.Message}"); + return Json(new { success = false, message = "Error retrieving status" }); + } + } + + // Request models - Add these classes to your project public class SaveAnswerConditionRequest { diff --git a/Web/Areas/Admin/Controllers/UserResponseController.cs b/Web/Areas/Admin/Controllers/UserResponseController.cs index fa3bd50..7748096 100644 --- a/Web/Areas/Admin/Controllers/UserResponseController.cs +++ b/Web/Areas/Admin/Controllers/UserResponseController.cs @@ -9,106 +9,270 @@ using Web.ViewModel.QuestionnaireVM; namespace Web.Areas.Admin.Controllers { - + public class UserResponseController : Controller { private readonly SurveyContext _context; private readonly IUserResponseRepository _userResponse; + private readonly ILogger _logger; - public UserResponseController(SurveyContext context, IUserResponseRepository userResponse) + public UserResponseController( + SurveyContext context, + IUserResponseRepository userResponse, + ILogger logger) { _context = context; _userResponse = userResponse; + _logger = logger; } + public async Task Index() { - var responses = await GetAllResponsesWithDetailsAsync(); // Fetch the data - return View(responses); // Pass the data to the view + try + { + var responses = await GetAllResponsesWithDetailsAsync(); + return View(responses); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving responses"); + TempData["Error"] = "Error loading responses. Please try again."; + return View(new List()); + } } private async Task> GetAllResponsesWithDetailsAsync() { return await _context.Responses - .Include(r => r.Questionnaire) // Ensure the Questionnaire data is included - .OrderBy(r => r.Id) // Optional: Order by submission date + .Include(r => r.Questionnaire) + .OrderByDescending(r => r.SubmissionDate) // Most recent first .ToListAsync(); } [HttpGet] - public async Task ViewResponse(int id) // Pass the response ID + public async Task ViewResponse(int id) { - var response = await _context.Responses + try + { + var response = await _context.Responses .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.Question) - .ThenInclude(q => q.Answers) // Load all possible answers for the questions + .ThenInclude(q => q.Answers) .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.ResponseAnswers) // Load the answers selected by the user + .ThenInclude(rd => rd.ResponseAnswers) + .Include(r => r.Questionnaire) .AsNoTracking() .FirstOrDefaultAsync(r => r.Id == id); - if (response == null) - { - return NotFound(); // If no response is found, return a NotFound result + if (response == null) + { + TempData["Error"] = "Response not found."; + return RedirectToAction(nameof(Index)); + } + + return View(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving response {ResponseId}", id); + TempData["Error"] = "Error loading response details."; + return RedirectToAction(nameof(Index)); } - - return View(response); // Pass the response to the view } - - public async Task UserResponsesStatus(string userName) { - var responses = await _userResponse.GetResponsesByUserAsync(userName); - - if (responses == null || !responses.Any()) + try { - return NotFound(); + var responses = await _userResponse.GetResponsesByUserAsync(userName); + if (responses == null || !responses.Any()) + { + TempData["Warning"] = "No responses found for this user."; + return RedirectToAction(nameof(Index)); + } + + var userEmail = responses.First().UserEmail; + var viewModel = new UserResponsesViewModel + { + UserName = userName, + UserEmail = userEmail, + Responses = responses.ToList() + }; + + return View(viewModel); } - - var userEmail = responses.First().UserEmail; - - var viewModel = new UserResponsesViewModel + catch (Exception ex) { - UserName = userName, - UserEmail = userEmail, - Responses = responses.ToList() - }; - - return View(viewModel); + _logger.LogError(ex, "Error retrieving user responses for {UserName}", userName); + TempData["Error"] = "Error loading user responses."; + return RedirectToAction(nameof(Index)); + } } - - - [HttpPost] [ValidateAntiForgeryToken] public async Task Delete(int id) { - var response = await _context.Responses.FindAsync(id); - if (response == null) + try { - return NotFound(); + var response = await _context.Responses + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .FirstOrDefaultAsync(r => r.Id == id); + + if (response == null) + { + TempData["Error"] = "Response not found."; + return RedirectToAction(nameof(Index)); + } + + // Remove related data first + if (response.ResponseDetails != null) + { + foreach (var detail in response.ResponseDetails) + { + if (detail.ResponseAnswers != null) + { + _context.ResponseAnswers.RemoveRange(detail.ResponseAnswers); + } + } + _context.ResponseDetails.RemoveRange(response.ResponseDetails); + } + + _context.Responses.Remove(response); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Response {ResponseId} deleted successfully", id); + TempData["Success"] = "Response deleted successfully."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting response {ResponseId}", id); + TempData["Error"] = "Error deleting response. Please try again."; } - _context.Responses.Remove(response); - await _context.SaveChangesAsync(); return RedirectToAction(nameof(Index)); } - [HttpPost] [ValidateAntiForgeryToken] - public async Task DeleteMultiple(int[] ids) + public async Task DeleteMultiple(List ids) { - var responses = _context.Responses.Where(r => ids.Contains(r.Id)); + if (ids == null || !ids.Any()) + { + TempData["Warning"] = "No responses selected for deletion."; + return RedirectToAction(nameof(Index)); + } + + try + { + _logger.LogInformation("Attempting to delete {Count} responses: {Ids}", ids.Count, string.Join(", ", ids)); + + var responses = await _context.Responses + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .Where(r => ids.Contains(r.Id)) + .ToListAsync(); + + if (!responses.Any()) + { + TempData["Warning"] = "No responses found to delete."; + return RedirectToAction(nameof(Index)); + } + + // Remove related data first + foreach (var response in responses) + { + if (response.ResponseDetails != null) + { + foreach (var detail in response.ResponseDetails) + { + if (detail.ResponseAnswers != null) + { + _context.ResponseAnswers.RemoveRange(detail.ResponseAnswers); + } + } + _context.ResponseDetails.RemoveRange(response.ResponseDetails); + } + } + + _context.Responses.RemoveRange(responses); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Successfully deleted {Count} responses", responses.Count); + TempData["Success"] = $"Successfully deleted {responses.Count} response{(responses.Count > 1 ? "s" : "")}."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting multiple responses. IDs: {Ids}", string.Join(", ", ids)); + TempData["Error"] = "Error deleting responses. Please try again."; + } - _context.Responses.RemoveRange(responses); - await _context.SaveChangesAsync(); - TempData["Success"] = "User response deleted successfully"; return RedirectToAction(nameof(Index)); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteAll() + { + try + { + var allResponses = await _context.Responses + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ToListAsync(); + if (!allResponses.Any()) + { + TempData["Warning"] = "No responses to delete."; + return RedirectToAction(nameof(Index)); + } + // Remove all related data + foreach (var response in allResponses) + { + if (response.ResponseDetails != null) + { + foreach (var detail in response.ResponseDetails) + { + if (detail.ResponseAnswers != null) + { + _context.ResponseAnswers.RemoveRange(detail.ResponseAnswers); + } + } + _context.ResponseDetails.RemoveRange(response.ResponseDetails); + } + } + + _context.Responses.RemoveRange(allResponses); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Successfully deleted all {Count} responses", allResponses.Count); + TempData["Success"] = $"Successfully deleted all {allResponses.Count} responses."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting all responses"); + TempData["Error"] = "Error deleting all responses. Please try again."; + } + + return RedirectToAction(nameof(Index)); + } + + // API endpoint to check if responses exist + [HttpGet] + public async Task CheckResponseExists(int id) + { + var exists = await _context.Responses.AnyAsync(r => r.Id == id); + return Json(new { exists }); + } + + // API endpoint to get response count + [HttpGet] + public async Task GetResponseCount() + { + var count = await _context.Responses.CountAsync(); + return Json(new { count }); + } } } diff --git a/Web/Areas/Admin/Controllers/UserResponseStatusController.cs b/Web/Areas/Admin/Controllers/UserResponseStatusController.cs index 4e84098..d5221bd 100644 --- a/Web/Areas/Admin/Controllers/UserResponseStatusController.cs +++ b/Web/Areas/Admin/Controllers/UserResponseStatusController.cs @@ -14,77 +14,198 @@ namespace Web.Areas.Admin.Controllers { private readonly SurveyContext _context; private readonly IUserResponseRepository _userResponse; + private readonly ILogger _logger; - public UserResponseStatusController(SurveyContext context, IUserResponseRepository userResponse) + public UserResponseStatusController( + SurveyContext context, + IUserResponseRepository userResponse, + ILogger logger) { _context = context; _userResponse = userResponse; + _logger = logger; } + public async Task Index() { - var usersWithQuestionnaires = await _context.Responses - .Include(r => r.Questionnaire) - .GroupBy(r => r.UserEmail) - .Select(g => new UserResponsesViewModel + try { - UserName = g.FirstOrDefault().UserName, // Display the first username found for the email - UserEmail = g.Key, - Responses = g.Select(r => new Response - { - Questionnaire = r.Questionnaire - }).Distinct().ToList() - }) - .ToListAsync(); + var usersWithQuestionnaires = await _context.Responses + .Include(r => r.Questionnaire) + .GroupBy(r => r.UserEmail) + .Select(g => new UserResponsesViewModel + { + UserName = g.FirstOrDefault().UserName, + UserEmail = g.Key, + Responses = g.Select(r => new Response + { + Questionnaire = r.Questionnaire + }).Distinct().ToList() + }) + .ToListAsync(); - return View(usersWithQuestionnaires); + return View(usersWithQuestionnaires); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving user responses"); + TempData["Error"] = "Error loading user responses. Please try again."; + return View(new List()); + } } public async Task UserResponsesStatus(string userEmail) { - var responses = await _context.Responses - .Include(r => r.Questionnaire) - .ThenInclude(q => q.Questions.OrderBy(qu => qu.Id)) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.Question) - .ThenInclude(q => q.Answers) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.ResponseAnswers) - .Where(r => r.UserEmail == userEmail) - .ToListAsync(); - - if (responses == null || !responses.Any()) + try { - return NotFound(); + var responses = await _context.Responses + .Include(r => r.Questionnaire) + .ThenInclude(q => q.Questions.OrderBy(qu => qu.Id)) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.Question) + .ThenInclude(q => q.Answers) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .Where(r => r.UserEmail == userEmail) + .ToListAsync(); + + if (responses == null || !responses.Any()) + { + TempData["Warning"] = "No responses found for this user."; + return RedirectToAction(nameof(Index)); + } + + var userName = responses.First().UserName; + + var viewModel = new UserResponsesViewModel + { + UserName = userName, + UserEmail = userEmail, + Responses = responses + }; + + return View(viewModel); } - - var userName = responses.First().UserName; - - var viewModel = new UserResponsesViewModel + catch (Exception ex) { - UserName = userName, - UserEmail = userEmail, - Responses = responses - }; - - return View(viewModel); + _logger.LogError(ex, "Error retrieving user responses for {UserEmail}", userEmail); + TempData["Error"] = "Error loading user response details."; + return RedirectToAction(nameof(Index)); + } } [HttpPost] - public async Task DeleteSelected(string[] selectedEmails) + [ValidateAntiForgeryToken] + public async Task DeleteSelected(List selectedEmails) { - if (selectedEmails == null || selectedEmails.Length == 0) + if (selectedEmails == null || !selectedEmails.Any()) { + TempData["Warning"] = "No users selected for deletion."; return RedirectToAction(nameof(Index)); } - var responsesToDelete = await _context.Responses - .Where(r => selectedEmails.Contains(r.UserEmail)) - .ToListAsync(); - - if (responsesToDelete.Any()) + try { + _logger.LogInformation("Attempting to delete responses for {Count} users: {Emails}", + selectedEmails.Count, string.Join(", ", selectedEmails)); + + var responsesToDelete = await _context.Responses + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .Where(r => selectedEmails.Contains(r.UserEmail)) + .ToListAsync(); + + if (!responsesToDelete.Any()) + { + TempData["Warning"] = "No responses found for the selected users."; + return RedirectToAction(nameof(Index)); + } + + // Remove related data first to avoid foreign key constraints + foreach (var response in responsesToDelete) + { + if (response.ResponseDetails != null) + { + foreach (var detail in response.ResponseDetails) + { + if (detail.ResponseAnswers != null) + { + _context.ResponseAnswers.RemoveRange(detail.ResponseAnswers); + } + } + _context.ResponseDetails.RemoveRange(response.ResponseDetails); + } + } + _context.Responses.RemoveRange(responsesToDelete); await _context.SaveChangesAsync(); + + _logger.LogInformation("Successfully deleted {Count} responses for {UserCount} users", + responsesToDelete.Count, selectedEmails.Count); + + TempData["Success"] = $"Successfully deleted responses for {selectedEmails.Count} user{(selectedEmails.Count > 1 ? "s" : "")} ({responsesToDelete.Count} response{(responsesToDelete.Count > 1 ? "s" : "")} total)."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting responses for users: {Emails}", string.Join(", ", selectedEmails)); + TempData["Error"] = "Error deleting user responses. Please try again."; + } + + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteUserResponses(string userEmail) + { + if (string.IsNullOrEmpty(userEmail)) + { + TempData["Error"] = "User email is required."; + return RedirectToAction(nameof(Index)); + } + + try + { + var responsesToDelete = await _context.Responses + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .Where(r => r.UserEmail == userEmail) + .ToListAsync(); + + if (!responsesToDelete.Any()) + { + TempData["Warning"] = "No responses found for this user."; + return RedirectToAction(nameof(Index)); + } + + // Remove related data first + foreach (var response in responsesToDelete) + { + if (response.ResponseDetails != null) + { + foreach (var detail in response.ResponseDetails) + { + if (detail.ResponseAnswers != null) + { + _context.ResponseAnswers.RemoveRange(detail.ResponseAnswers); + } + } + _context.ResponseDetails.RemoveRange(response.ResponseDetails); + } + } + + _context.Responses.RemoveRange(responsesToDelete); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Successfully deleted {Count} responses for user {UserEmail}", + responsesToDelete.Count, userEmail); + + TempData["Success"] = $"Successfully deleted all responses for user {userEmail}."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting responses for user {UserEmail}", userEmail); + TempData["Error"] = "Error deleting user responses. Please try again."; } return RedirectToAction(nameof(Index)); @@ -92,29 +213,40 @@ namespace Web.Areas.Admin.Controllers public async Task GenerateReport(string userEmail, string format) { - var responses = await _context.Responses - .Include(r => r.Questionnaire) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.Question) - .ThenInclude(q => q.Answers) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.ResponseAnswers) - .Where(r => r.UserEmail == userEmail) - .ToListAsync(); - - if (responses == null || !responses.Any()) + try { - return NotFound(); + var responses = await _context.Responses + .Include(r => r.Questionnaire) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.Question) + .ThenInclude(q => q.Answers) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .Where(r => r.UserEmail == userEmail) + .ToListAsync(); + + if (responses == null || !responses.Any()) + { + TempData["Warning"] = "No responses found for this user."; + return RedirectToAction(nameof(Index)); + } + + switch (format.ToLower()) + { + case "pdf": + return GeneratePdfReport(responses); + case "excel": + return GenerateExcelReport(responses); + default: + TempData["Error"] = "Unsupported report format."; + return RedirectToAction(nameof(Index)); + } } - - switch (format.ToLower()) + catch (Exception ex) { - case "pdf": - return GeneratePdfReport(responses); - case "excel": - return GenerateExcelReport(responses); - default: - return BadRequest("Unsupported report format."); + _logger.LogError(ex, "Error generating report for user {UserEmail}", userEmail); + TempData["Error"] = "Error generating report. Please try again."; + return RedirectToAction(nameof(Index)); } } @@ -126,7 +258,7 @@ namespace Web.Areas.Admin.Controllers var stream = new MemoryStream(); var document = new Document(PageSize.A4, 50, 50, 25, 25); var writer = PdfWriter.GetInstance(document, stream); - writer.CloseStream = false; // Prevent the stream from being closed when the document is closed + writer.CloseStream = false; document.Open(); @@ -190,7 +322,7 @@ namespace Web.Areas.Admin.Controllers table.AddCell(new PdfPCell(new Phrase("Answers:", cellFont)) { Padding = 5 }); var answers = string.Join(", ", detail.ResponseAnswers.Select(a => detail.Question.Answers.FirstOrDefault(ans => ans.Id == a.AnswerId)?.Text)); - // NEW: Include "Other" text if available + // Include "Other" text if available if (!string.IsNullOrEmpty(detail.OtherText)) { answers += string.IsNullOrEmpty(answers) @@ -228,7 +360,7 @@ namespace Web.Areas.Admin.Controllers var logo = new FileInfo(logoPath); var picture = worksheet.Drawings.AddPicture("Logo", logo); picture.SetPosition(0, 0, 0, 0); - picture.SetSize(300, 70); // Adjust the size as needed + picture.SetSize(300, 70); } // Add a title @@ -272,7 +404,7 @@ namespace Web.Areas.Admin.Controllers { var answers = string.Join(", ", detail.ResponseAnswers.Select(a => detail.Question.Answers.FirstOrDefault(ans => ans.Id == a.AnswerId)?.Text)); - // NEW: Include "Other" text if available + // Include "Other" text if available if (!string.IsNullOrEmpty(detail.OtherText)) { answers += string.IsNullOrEmpty(answers) @@ -299,21 +431,31 @@ namespace Web.Areas.Admin.Controllers public async Task GenerateQuestionnairePdfReport(int questionnaireId) { - var response = await _context.Responses - .Include(r => r.Questionnaire) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.Question) - .ThenInclude(q => q.Answers) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.ResponseAnswers) - .FirstOrDefaultAsync(r => r.QuestionnaireId == questionnaireId); - - if (response == null) + try { - return NotFound(); - } + var response = await _context.Responses + .Include(r => r.Questionnaire) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.Question) + .ThenInclude(q => q.Answers) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .FirstOrDefaultAsync(r => r.QuestionnaireId == questionnaireId); - return GeneratePdfReportForQuestionnaire(response); + if (response == null) + { + TempData["Warning"] = "No response found for this questionnaire."; + return RedirectToAction(nameof(Index)); + } + + return GeneratePdfReportForQuestionnaire(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating questionnaire PDF report for {QuestionnaireId}", questionnaireId); + TempData["Error"] = "Error generating PDF report. Please try again."; + return RedirectToAction(nameof(Index)); + } } private IActionResult GeneratePdfReportForQuestionnaire(Response response) @@ -324,7 +466,7 @@ namespace Web.Areas.Admin.Controllers var stream = new MemoryStream(); var document = new Document(PageSize.A4, 50, 50, 25, 25); var writer = PdfWriter.GetInstance(document, stream); - writer.CloseStream = false; // Prevent the stream from being closed when the document is closed + writer.CloseStream = false; document.Open(); @@ -394,7 +536,7 @@ namespace Web.Areas.Admin.Controllers table.AddCell(new PdfPCell(new Phrase("Answers:", cellFont)) { Padding = 5 }); var answers = string.Join(", ", detail.ResponseAnswers.Select(a => detail.Question.Answers.FirstOrDefault(ans => ans.Id == a.AnswerId)?.Text)); - // NEW: Include "Other" text if available + // Include "Other" text if available if (!string.IsNullOrEmpty(detail.OtherText)) { answers += string.IsNullOrEmpty(answers) @@ -416,21 +558,31 @@ namespace Web.Areas.Admin.Controllers public async Task GenerateQuestionnaireExcelReport(int questionnaireId) { - var response = await _context.Responses - .Include(r => r.Questionnaire) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.Question) - .ThenInclude(q => q.Answers) - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.ResponseAnswers) - .FirstOrDefaultAsync(r => r.QuestionnaireId == questionnaireId); - - if (response == null) + try { - return NotFound(); - } + var response = await _context.Responses + .Include(r => r.Questionnaire) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.Question) + .ThenInclude(q => q.Answers) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .FirstOrDefaultAsync(r => r.QuestionnaireId == questionnaireId); - return GenerateExcelReportForQuestionnaire(response); + if (response == null) + { + TempData["Warning"] = "No response found for this questionnaire."; + return RedirectToAction(nameof(Index)); + } + + return GenerateExcelReportForQuestionnaire(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating questionnaire Excel report for {QuestionnaireId}", questionnaireId); + TempData["Error"] = "Error generating Excel report. Please try again."; + return RedirectToAction(nameof(Index)); + } } private IActionResult GenerateExcelReportForQuestionnaire(Response response) @@ -449,7 +601,7 @@ namespace Web.Areas.Admin.Controllers var logo = new FileInfo(logoPath); var picture = worksheet.Drawings.AddPicture("Logo", logo); picture.SetPosition(0, 0, 2, 0); - picture.SetSize(300, 60); // Adjust the size as needed + picture.SetSize(300, 60); } // Add user details @@ -498,7 +650,7 @@ namespace Web.Areas.Admin.Controllers { var answers = string.Join(", ", detail.ResponseAnswers.Select(a => detail.Question.Answers.FirstOrDefault(ans => ans.Id == a.AnswerId)?.Text)); - // NEW: Include "Other" text if available + // Include "Other" text if available if (!string.IsNullOrEmpty(detail.OtherText)) { answers += string.IsNullOrEmpty(answers) @@ -520,5 +672,21 @@ namespace Web.Areas.Admin.Controllers return File(stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"{response.Questionnaire.Title}_{userEmail}.xlsx"); } } + + // API endpoint to check if user responses exist + [HttpGet] + public async Task CheckUserResponsesExist(string userEmail) + { + var exists = await _context.Responses.AnyAsync(r => r.UserEmail == userEmail); + return Json(new { exists }); + } + + // API endpoint to get user response count + [HttpGet] + public async Task GetUserResponseCount() + { + var count = await _context.Responses.GroupBy(r => r.UserEmail).CountAsync(); + return Json(new { count }); + } } } \ No newline at end of file diff --git a/Web/Areas/Admin/Views/Questionnaire/Index.cshtml b/Web/Areas/Admin/Views/Questionnaire/Index.cshtml index 11c6fdd..2df70bc 100644 --- a/Web/Areas/Admin/Views/Questionnaire/Index.cshtml +++ b/Web/Areas/Admin/Views/Questionnaire/Index.cshtml @@ -5,7 +5,7 @@ }
+ + @if (TempData["Success"] != null) + { + + } + + @if (TempData["Error"] != null) + { + + } + + @if (TempData["Warning"] != null) + { + + } + - + @if (Model.Any()) {
@@ -691,16 +1025,24 @@ Total Questionnaires
- @Model.Sum(q => q.Questions?.Count ?? 0) + @Model.Count(q => q.Status == QuestionnaireStatus.Draft) + Draft +
+
+ @Model.Count(q => q.Status == QuestionnaireStatus.Published) + Published +
+
+ @Model.Count(q => q.Status == QuestionnaireStatus.Archived) + Archived +
+
+ @Model.Sum(q => q.ActiveQuestionCount) Total Questions
- @Model.Sum(q => q.Questions?.Sum(x => x.Answers?.Count ?? 0) ?? 0) - Total Answers -
-
- @Model.SelectMany(q => q.Questions ?? new List()).Select(q => q.Type).Distinct().Count() - Question Types + @Model.Count(q => q.HasResponses) + With Responses
@@ -713,6 +1055,26 @@ @foreach (var item in Model.Select((questionnaire, index) => new { Questionnaire = questionnaire, Index = index })) {
+ + @switch (item.Questionnaire.Status) + { + case QuestionnaireStatus.Draft: +
+ Draft +
+ break; + case QuestionnaireStatus.Published: +
+ Live +
+ break; + case QuestionnaireStatus.Archived: +
+ Archived +
+ break; + } +
ID: @item.Questionnaire.Id @@ -721,6 +1083,39 @@
+ +
+
+
+
+ +
+
Created
+
@item.Questionnaire.CreatedDate.ToString("MMM dd")
+
+
+
+ +
+
Questions
+
@item.Questionnaire.ActiveQuestionCount
+
+
+
+ +
+
Responses
+
@(item.Questionnaire.HasResponses ? "Yes" : "None")
+
+
+ @if (item.Questionnaire.PublishedDate.HasValue) + { +
+ Published: @item.Questionnaire.PublishedDate.Value.ToString("MMM dd, yyyy") +
+ } +
+
@(item.Questionnaire.Questions?.Count ?? 0) @@ -791,29 +1186,117 @@ }
- +
- - - Edit - + Details - - - Delete - - - - Send - - - - Set Logic - + + @switch (item.Questionnaire.Status) + { + case QuestionnaireStatus.Draft: + + + + Edit + + + @if (item.Questionnaire.ActiveQuestionCount > 0) + { + + } + else + { +
+ + Publish +
+ } + + + + Delete + + + + + Set Logic + + break; + + case QuestionnaireStatus.Published: + + + + Edit* + + + + + Send + + + + + + + Set Logic + + break; + + case QuestionnaireStatus.Archived: + +
+ + Read Only +
+ + @if (!item.Questionnaire.HasResponses) + { + + } + else + { +
+ + Revert +
+ } + +
+ + Send +
+ +
+ + Set Logic +
+ break; + }
+ + @if (item.Questionnaire.Status == QuestionnaireStatus.Published) + { +
+ * Limited editing - questions with responses cannot be fully modified +
+ }
}
@@ -833,4 +1316,228 @@
} - \ No newline at end of file + + + +
+ +
+ + + + + + + + + \ No newline at end of file diff --git a/Web/Areas/Admin/Views/Questionnaire/SendQuestionnaire.cshtml b/Web/Areas/Admin/Views/Questionnaire/SendQuestionnaire.cshtml index 3880002..ef97d5c 100644 --- a/Web/Areas/Admin/Views/Questionnaire/SendQuestionnaire.cshtml +++ b/Web/Areas/Admin/Views/Questionnaire/SendQuestionnaire.cshtml @@ -1,48 +1,905 @@ @model SendQuestionnaireViewModel - @{ - ViewData["Title"] = "Send"; + ViewData["Title"] = "Send Questionnaire"; } + + +
+ + + + @if (TempData["Success"] != null) + { + + } + + @if (TempData["Error"] != null) + { + + } + + + + + + + + +
+
+
+
+ +
+

Questionnaire Distribution Center

+
+
+ +
+
+
+ + +
+ + + +
+ + +
+ + + +
+ + Pro Tip: Copy and paste email lists from Excel, Google Sheets, or any text document. The system will automatically format them correctly. +
+
+ + +
+ + + + +
+ + + + + +
+ +
+
+ \ No newline at end of file diff --git a/Web/Areas/Admin/Views/Questionnaire/StatusGuide.cshtml b/Web/Areas/Admin/Views/Questionnaire/StatusGuide.cshtml new file mode 100644 index 0000000..064a100 --- /dev/null +++ b/Web/Areas/Admin/Views/Questionnaire/StatusGuide.cshtml @@ -0,0 +1,574 @@ +@{ + ViewData["Title"] = "Questionnaire Status Guide"; + +} + + + + + + +
+
+
+

Questionnaire Status Guide

+

Complete breakdown of what you can do in each status

+
+ +
+ +
+
+
+
+

Draft

+

Full editing freedom

+
+
+ +
+

What You CAN Do

+
    +
  • +
    + Edit questionnaire title and description +
  • +
  • +
    + Add new questions +
  • +
  • +
    + Edit existing questions (text, type) +
  • +
  • +
    + Delete questions completely +
  • +
  • +
    + Add/edit/delete answers +
  • +
  • +
    + Reorder questions +
  • +
  • +
    + Delete entire questionnaire +
  • +
  • +
    + Publish questionnaire +
  • +
+
+ +
+

What You CANNOT Do

+
    +
  • +
    + Send to respondents (must publish first) +
  • +
  • +
    + Collect responses (not live yet) +
  • +
+
+ +
+

Best Practices

+
• Perfect your questions before publishing
+
• Test question flow and logic
+
• Review with team members
+
• Preview how it looks to respondents
+
+
+ + +
+
+
+
+

Published

+

Live & accepting responses

+
+
+ +
+

What You CAN Do

+
    +
  • +
    + Edit questionnaire title and description +
  • +
  • +
    + Edit question text (preserve meaning) +
  • +
  • +
    + Add new questions +
  • +
  • +
    + Send questionnaire to respondents +
  • +
  • +
    + View responses and analytics +
  • +
  • +
    + Archive questionnaire +
  • +
+
+ +
+

Limited Actions

+
    +
  • +
    + Remove questions (hidden, not deleted) +
  • +
  • +
    + Edit answers (only for questions without responses) +
  • +
+
+ +
+

What You CANNOT Do

+
    +
  • +
    + Delete questionnaire (has responses) +
  • +
  • +
    + Permanently delete questions with responses +
  • +
  • +
    + Change answers for questions with responses +
  • +
  • +
    + Change question types that affect existing responses +
  • +
+
+ +
+

Best Practices

+
• Make only essential text changes
+
• Add new questions at the end
+
• Monitor response data regularly
+
• Consider creating a new version for major changes
+
+
+ + +
+
+
+
+

Archived

+

Read-only & complete

+
+
+ +
+

What You CAN Do

+
    +
  • +
    + View questionnaire details +
  • +
  • +
    + View all responses and data +
  • +
  • +
    + Export response data +
  • +
  • +
    + Generate reports and analytics +
  • +
  • +
    + Duplicate questionnaire as new draft +
  • +
+
+ +
+

Limited Actions

+
    +
  • +
    + Revert to draft (only if no responses) +
  • +
+
+ +
+

What You CANNOT Do

+
    +
  • +
    + Edit any questionnaire content +
  • +
  • +
    + Add or remove questions +
  • +
  • +
    + Send to new respondents +
  • +
  • +
    + Collect new responses +
  • +
  • +
    + Delete questionnaire (permanent archive) +
  • +
+
+ +
+

Best Practices

+
• Export all data before long-term storage
+
• Create final analysis reports
+
• Document key insights and findings
+
• Use as template for future questionnaires
+
+
+
+ +
+

Status Workflow

+
+
+
+
Draft
+
Create & perfect your questionnaire
+
+
+
+
+
Published
+
Live & collecting responses
+
+
+
+
+
Archived
+
Complete & preserved
+
+
+ +
+
+ + Key Benefit: This status system completely eliminates foreign key constraint errors while providing professional survey management! +
+
+
+
+
diff --git a/Web/Areas/Admin/Views/UserResponse/Index.cshtml b/Web/Areas/Admin/Views/UserResponse/Index.cshtml index 0ae3a10..a69df39 100644 --- a/Web/Areas/Admin/Views/UserResponse/Index.cshtml +++ b/Web/Areas/Admin/Views/UserResponse/Index.cshtml @@ -333,42 +333,103 @@ position: relative; } + /* Response Checkbox - Simplified and Reliable */ .response-checkbox { position: absolute; top: 1rem; right: 1rem; - z-index: 10; + z-index: 20; } - .custom-checkbox { - width: 24px; - height: 24px; - border: 2px solid var(--gray-300); - border-radius: 6px; + .checkbox-label { + cursor: pointer; + margin: 0; + padding: 0; + display: block; + } + + .response-checkbox-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + margin: 0; + padding: 0; + } + + .checkbox-custom { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 3px solid var(--gray-300); + border-radius: 8px; + background: white; + transition: all 0.2s ease; + box-shadow: var(--shadow-md); + cursor: pointer; + } + + .checkbox-custom:hover { + border-color: var(--primary-color); + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15); + transform: scale(1.05); + } + + .checkbox-custom i { + font-size: 18px; + font-weight: bold; + color: white; + opacity: 0; + transition: opacity 0.2s ease; + } + + .response-checkbox-input:checked + .checkbox-custom { + background: var(--primary-color); + border-color: var(--primary-color); + } + + .response-checkbox-input:checked + .checkbox-custom i { + opacity: 1; + } + + /* Master Checkbox - Updated to match */ + .master-checkbox { + width: 32px; + height: 32px; + border: 3px solid var(--gray-300); + border-radius: 8px; background: white; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; - box-shadow: var(--shadow-sm); + box-shadow: var(--shadow-md); } - .custom-checkbox:hover { + .master-checkbox:hover { border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15); + transform: scale(1.05); } - .custom-checkbox.checked { + .master-checkbox.checked { background: var(--primary-color); border-color: var(--primary-color); color: white; } - .custom-checkbox input { + .master-checkbox input { display: none; } + .master-checkbox i { + font-size: 18px; + font-weight: bold; + } + .response-info { padding-right: 3rem; } @@ -535,6 +596,7 @@ box-shadow: var(--shadow-sm); position: relative; overflow: hidden; + cursor: pointer; } .btn::before { @@ -576,6 +638,18 @@ color: white; } + .btn-warning { + background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-dark) 100%); + color: white; + } + + .btn-warning:hover { + background: linear-gradient(135deg, var(--warning-dark) 0%, #b45309 100%); + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + color: white; + } + /* Empty State */ .empty-state { text-align: center; @@ -673,6 +747,264 @@ } } + /* Confirmation Modal Styles */ + .confirmation-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .confirmation-modal.show { + opacity: 1; + visibility: visible; + } + + .modal-content { + background: white; + border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-2xl); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow: hidden; + transform: translateY(20px) scale(0.95); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + } + + .confirmation-modal.show .modal-content { + transform: translateY(0) scale(1); + } + + .modal-header { + padding: 3rem 2rem 2rem 2rem; + text-align: center; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + } + + /* Icon Row */ + .modal-icon-row { + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 3rem; + } + + .modal-icon { + width: 100px; + height: 100px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + position: relative; + overflow: hidden; + } + + .modal-icon::before { + content: ''; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + border-radius: 50%; + z-index: -1; + opacity: 0.3; + } + + .modal-icon.delete-selected { + background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-light) 50%, var(--danger-dark) 100%); + color: white; + box-shadow: 0 20px 40px rgba(239, 68, 68, 0.4), 0 8px 16px rgba(239, 68, 68, 0.2); + } + + .modal-icon.delete-selected::before { + background: linear-gradient(135deg, var(--danger-light) 0%, var(--danger-color) 100%); + } + + .modal-icon.delete-all { + background: linear-gradient(135deg, var(--danger-dark) 0%, var(--danger-color) 50%, #b91c1c 100%); + color: white; + box-shadow: 0 20px 40px rgba(220, 38, 38, 0.5), 0 8px 16px rgba(220, 38, 38, 0.3); + } + + .modal-icon.delete-all::before { + background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-dark) 100%); + } + + /* Icon symbol styling */ + .modal-icon i { + position: relative; + z-index: 1; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + } + + /* Title Row */ + .modal-title-row { + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 2rem; + } + + .modal-title { + font-size: 1.875rem; + font-weight: 800; + color: var(--gray-800); + margin: 0; + line-height: 1.2; + text-align: center; + } + + /* Subtitle Row */ + .modal-subtitle-row { + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 0; + } + + .modal-subtitle { + color: var(--gray-600); + font-size: 1.1rem; + font-weight: 500; + margin: 0; + line-height: 1.4; + text-align: center; + max-width: 400px; + } + + .modal-body { + padding: 1.5rem 2rem; + } + + .modal-description { + background: var(--gray-50); + border-radius: var(--border-radius-md); + padding: 1.5rem; + margin-bottom: 1.5rem; + border: 1px solid var(--gray-200); + } + + .modal-description h4 { + font-size: 1rem; + font-weight: 700; + color: var(--gray-800); + margin: 0 0 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .modal-description ul { + margin: 0; + padding-left: 1.25rem; + color: var(--gray-600); + } + + .modal-description li { + margin-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.5; + } + + .selection-info { + background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-light) 100%); + color: white; + padding: 1rem; + border-radius: var(--border-radius-md); + margin-bottom: 1.5rem; + text-align: center; + } + + .selection-count { + font-size: 1.125rem; + font-weight: 700; + margin: 0 0 0.5rem; + } + + .selection-meta { + font-size: 0.875rem; + opacity: 0.9; + margin: 0; + } + + .modal-actions { + display: flex; + gap: 1rem; + padding: 2rem; + background: var(--gray-50); + border-top: 1px solid var(--gray-200); + } + + .modal-btn { + flex: 1; + padding: 1rem 1.5rem; + border-radius: var(--border-radius-md); + font-weight: 700; + font-size: 1rem; + border: none; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + box-shadow: var(--shadow-md); + } + + .modal-btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + } + + .modal-btn-cancel { + background: white; + color: var(--gray-700); + border: 2px solid var(--gray-300); + } + + .modal-btn-cancel:hover { + background: var(--gray-50); + border-color: var(--gray-400); + } + + .modal-btn-confirm { + color: white; + } + + .modal-btn-confirm.delete-selected { + background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-dark) 100%); + } + + .modal-btn-confirm.delete-selected:hover { + background: linear-gradient(135deg, var(--danger-dark) 0%, #b91c1c 100%); + } + + .modal-btn-confirm.delete-all { + background: linear-gradient(135deg, var(--danger-dark) 0%, #b91c1c 100%); + } + + .modal-btn-confirm.delete-all:hover { + background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%); + } + /* Animations */ .fade-in { animation: fadeIn 0.6s ease-out; @@ -702,10 +1034,35 @@ .stagger-animation:nth-child(6) { animation-delay: 0.6s; } -
+
+ + @if (TempData["Success"] != null) + { + + } + + @if (TempData["Error"] != null) + { + + } + + @if (TempData["Warning"] != null) + { + + } + -
- @if (Model.Any()) - { - -
-
-
- @Model.Count() - Total Responses -
-
- @Model.Select(r => r.UserEmail).Distinct().Count() - Unique Users -
-
- @Model.Select(r => r.Questionnaire?.Title).Distinct().Count() - Questionnaires -
-
- @Model.Where(r => r.SubmissionDate.Date == DateTime.Today).Count() - Today's Responses -
+ @if (Model.Any()) + { + +
+
+
+ @Model.Count() + Total Responses +
+
+ @Model.Select(r => r.UserEmail).Distinct().Count() + Unique Users +
+
+ @Model.Select(r => r.Questionnaire?.Title).Distinct().Count() + Questionnaires +
+
+ @Model.Where(r => r.SubmissionDate.Date == DateTime.Today).Count() + Today's Responses
+
- -
-
- - -
- -
- 0 selected -
+ +
+
+ +
+ +
+ 0 selected +
+
- -
-
-
-
- -
-
-

Selection Active

-

0 responses selected

-
+ +
+
+
+
+
-
- - +
+

Selection Active

+

0 responses selected

+
+ + + +
+
- -
- @foreach (var item in Model.Select((response, index) => new { Response = response, Index = index })) - { -
- -
-
- - -
-
+ + + @Html.AntiForgeryToken() +
+ - -
-
- ID: @item.Response.Id -

@item.Response.Questionnaire?.Title

-