Edit button locate, delete, group header

This commit is contained in:
ivan-pelly
2026-04-10 17:52:52 -07:00
parent b287276ec0
commit 0036e25b9c
25 changed files with 629 additions and 82 deletions
+96
View File
@@ -324,6 +324,38 @@ public class StudentController : BaseController
}); });
} }
[HttpDelete("{idStudent:guid}/goals/{idGoal:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<object>>> DeleteGoal(Guid idStudent, Guid idGoal)
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role, "all");
if (!students.Select(s => s.StudentId).Contains(idStudent))
{
return NotFound(new ResponseResult<object>
{
Success = false,
Message = "Student not found."
});
}
var deleted = await _studentRepository.DeleteGoalAsync(idGoal);
return Ok(new ResponseResult<object>
{
Success = true,
Message = deleted ? "Goal deleted." : "Goal not found."
});
}
[HttpPost("{idStudent:guid}/progress-event")] [HttpPost("{idStudent:guid}/progress-event")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)]
@@ -412,6 +444,38 @@ public class StudentController : BaseController
} }
} }
[HttpDelete("{idStudent:guid}/progress-events/{idProgressEvent:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<object>>> DeleteProgressEvent(Guid idStudent, Guid idProgressEvent)
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role, "all");
if (!students.Select(s => s.StudentId).Contains(idStudent))
{
return NotFound(new ResponseResult<object>
{
Success = false,
Message = "Student not found."
});
}
var deleted = await _studentRepository.DeleteProgressEventAsync(idProgressEvent);
return Ok(new ResponseResult<object>
{
Success = true,
Message = deleted ? "Progress event deleted." : "Progress event not found."
});
}
[HttpGet("progress-events/{idProgressEvent:guid}/benchmarks")] [HttpGet("progress-events/{idProgressEvent:guid}/benchmarks")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<List<Guid>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult<List<Guid>>), StatusCodes.Status200OK)]
@@ -676,6 +740,38 @@ public class StudentController : BaseController
}); });
} }
[HttpDelete("{idStudent:guid}/benchmarks/{idBenchmark:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<object>>> DeleteBenchmark(Guid idStudent, Guid idBenchmark)
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role, "all");
if (!students.Select(s => s.StudentId).Contains(idStudent))
{
return NotFound(new ResponseResult<object>
{
Success = false,
Message = "Student not found."
});
}
var deleted = await _studentRepository.DeleteBenchmarkAsync(idBenchmark);
return Ok(new ResponseResult<object>
{
Success = true,
Message = deleted ? "Benchmark deleted." : "Benchmark not found."
});
}
[HttpGet("{idStudent:guid}/progress-report")] [HttpGet("{idStudent:guid}/progress-report")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<string>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult<string>), StatusCodes.Status200OK)]
@@ -145,6 +145,19 @@ public class StudentRepository
return rows.Select(r => r.benchmarkId is Guid g ? g : Guid.Parse((string)r.benchmarkId)).ToList(); return rows.Select(r => r.benchmarkId is Guid g ? g : Guid.Parse((string)r.benchmarkId)).ToList();
} }
// *****************************************************************
// Deletes a progress event and its benchmark associations.
// *****************************************************************
public async Task<bool> DeleteProgressEventAsync(Guid progressEventId)
{
using var db = Connection;
var rowsAffected = await db.ExecuteScalarAsync<int>(
"sp_ProgressEvent_Delete",
new { p_id_progress_event = progressEventId.ToString() },
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
public async Task<Guid?> GetStudentIdForGoalAsync(Guid idGoal) public async Task<Guid?> GetStudentIdForGoalAsync(Guid idGoal)
{ {
using var db = Connection; using var db = Connection;
@@ -276,6 +289,20 @@ public class StudentRepository
return rowsAffected > 0; return rowsAffected > 0;
} }
// *****************************************************************
// Deletes a goal and all its child entities (benchmarks, progress
// events, event-benchmark links, child goals) via cascade SP.
// *****************************************************************
public async Task<bool> DeleteGoalAsync(Guid goalId)
{
using var db = Connection;
var rowsAffected = await db.ExecuteScalarAsync<int>(
"sp_Goal_Delete",
new { p_id_goal = goalId.ToString() },
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
// ***************************************************************** // *****************************************************************
// Returns all benchmarks for a student, grouped under a summary // Returns all benchmarks for a student, grouped under a summary
// with the student identifier. Returns null if student not found. // with the student identifier. Returns null if student not found.
@@ -367,6 +394,19 @@ public class StudentRepository
return rowsAffected > 0; return rowsAffected > 0;
} }
// *****************************************************************
// Deletes a benchmark and its progress-event associations.
// *****************************************************************
public async Task<bool> DeleteBenchmarkAsync(Guid benchmarkId)
{
using var db = Connection;
var rowsAffected = await db.ExecuteScalarAsync<int>(
"sp_Benchmark_Delete",
new { p_id_benchmark = benchmarkId.ToString() },
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
// ***************************************************************** // *****************************************************************
// Returns a full student profile: student card, goals, benchmarks, // Returns a full student profile: student card, goals, benchmarks,
// progress events, and benchmark/event associations in one call. // progress events, and benchmark/event associations in one call.
@@ -0,0 +1,12 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Benchmark_Delete`(IN p_id_benchmark CHAR(36))
BEGIN
-- Remove progress-event/benchmark associations
DELETE FROM progress_event_benchmark
WHERE id_benchmark = p_id_benchmark;
-- Remove the benchmark itself
DELETE FROM benchmark
WHERE id_benchmark = p_id_benchmark;
SELECT ROW_COUNT() AS rows_affected;
END;;
DELIMITER ;
+22
View File
@@ -0,0 +1,22 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Goal_Delete`(IN p_id_goal CHAR(36))
BEGIN
-- Remove benchmark/event associations for progress events under this goal
DELETE peb FROM progress_event_benchmark peb
INNER JOIN progress_event pe ON pe.id_progress_event = peb.id_progress_event
WHERE pe.id_goal = p_id_goal;
-- Remove progress events under this goal
DELETE FROM progress_event
WHERE id_goal = p_id_goal;
-- Remove benchmarks under this goal
DELETE FROM benchmark
WHERE id_goal = p_id_goal;
-- Remove child goals (one level)
DELETE FROM goal
WHERE id_goal_parent = p_id_goal;
-- Remove the goal itself
DELETE FROM goal
WHERE id_goal = p_id_goal;
SELECT ROW_COUNT() AS rows_affected;
END;;
DELIMITER ;
@@ -0,0 +1,12 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ProgressEvent_Delete`(IN p_id_progress_event CHAR(36))
BEGIN
-- Remove benchmark associations
DELETE FROM progress_event_benchmark
WHERE id_progress_event = p_id_progress_event;
-- Remove the progress event itself
DELETE FROM progress_event
WHERE id_progress_event = p_id_progress_event;
SELECT ROW_COUNT() AS rows_affected;
END;;
DELIMITER ;
@@ -4,7 +4,7 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_ReportPrompt_Delete`(
) )
BEGIN BEGIN
DELETE FROM `ReportPrompt` DELETE FROM `ReportPrompt`
WHERE `id_report_prompt` = p_id_report_prompt; WHERE `id_ReportPrompt` = p_id_report_prompt;
SELECT ROW_COUNT() AS rowsAffected; SELECT ROW_COUNT() AS rowsAffected;
END;; END;;
DELIMITER ; DELIMITER ;
@@ -2,7 +2,7 @@ DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_ReportPrompt_GetAll`() CREATE DEFINER=`root`@`%` PROCEDURE `sp_ReportPrompt_GetAll`()
BEGIN BEGIN
SELECT SELECT
`id_report_prompt` AS `reportPromptId`, `id_ReportPrompt` AS `reportPromptId`,
`id_program` AS `programId`, `id_program` AS `programId`,
`prompt` AS `prompt`, `prompt` AS `prompt`,
`reportname` AS `reportname` `reportname` AS `reportname`
@@ -4,12 +4,12 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_ReportPrompt_GetById`(
) )
BEGIN BEGIN
SELECT SELECT
`id_report_prompt` AS `reportPromptId`, `id_ReportPrompt` AS `reportPromptId`,
`id_program` AS `programId`, `id_program` AS `programId`,
`prompt` AS `prompt`, `prompt` AS `prompt`,
`reportname` AS `reportname` `reportname` AS `reportname`
FROM `ReportPrompt` FROM `ReportPrompt`
WHERE `id_report_prompt` = p_id_report_prompt WHERE `id_ReportPrompt` = p_id_report_prompt
LIMIT 1; LIMIT 1;
END;; END;;
DELIMITER ; DELIMITER ;
@@ -5,7 +5,7 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_ReportPrompt_GetByReportname`(
) )
BEGIN BEGIN
SELECT SELECT
`id_report_prompt` AS `reportPromptId`, `id_ReportPrompt` AS `reportPromptId`,
`id_program` AS `programId`, `id_program` AS `programId`,
`prompt` AS `prompt`, `prompt` AS `prompt`,
`reportname` AS `reportname` `reportname` AS `reportname`
@@ -8,7 +8,7 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_ReportPrompt_Insert`(
BEGIN BEGIN
INSERT INTO `ReportPrompt` INSERT INTO `ReportPrompt`
( (
`id_report_prompt`, `id_ReportPrompt`,
`id_program`, `id_program`,
`prompt`, `prompt`,
`reportname` `reportname`
@@ -21,12 +21,12 @@ BEGIN
p_reportname p_reportname
); );
SELECT SELECT
`id_report_prompt` AS `reportPromptId`, `id_ReportPrompt` AS `reportPromptId`,
`id_program` AS `programId`, `id_program` AS `programId`,
`prompt` AS `prompt`, `prompt` AS `prompt`,
`reportname` AS `reportname` `reportname` AS `reportname`
FROM `ReportPrompt` FROM `ReportPrompt`
WHERE `id_report_prompt` = p_id_report_prompt WHERE `id_ReportPrompt` = p_id_report_prompt
LIMIT 1; LIMIT 1;
END;; END;;
DELIMITER ; DELIMITER ;
@@ -7,9 +7,9 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_ReportPrompt_Update`(
BEGIN BEGIN
UPDATE `ReportPrompt` UPDATE `ReportPrompt`
SET SET
`prompt` = p_prompt, `prompt` = COALESCE(p_prompt, `prompt`),
`reportname` = p_reportname `reportname` = COALESCE(p_reportname, `reportname`)
WHERE `id_report_prompt` = p_id_report_prompt; WHERE `id_ReportPrompt` = p_id_report_prompt;
SELECT ROW_COUNT() AS rowsAffected; SELECT ROW_COUNT() AS rowsAffected;
END;; END;;
DELIMITER ; DELIMITER ;
@@ -1,8 +1,26 @@
DELIMITER ;; DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36)) CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36))
BEGIN BEGIN
-- Remove progress-event/benchmark associations
DELETE peb FROM progress_event_benchmark peb
INNER JOIN progress_event pe ON pe.id_progress_event = peb.id_progress_event
INNER JOIN goal g ON g.id_goal = pe.id_goal
WHERE g.id_student = p_id_student;
-- Remove progress events
DELETE pe FROM progress_event pe
INNER JOIN goal g ON g.id_goal = pe.id_goal
WHERE g.id_student = p_id_student;
-- Remove benchmarks
DELETE b FROM benchmark b
INNER JOIN goal g ON g.id_goal = b.id_goal
WHERE g.id_student = p_id_student;
-- Remove goals
DELETE FROM goal
WHERE id_student = p_id_student;
-- Remove user-student associations
DELETE FROM user_student DELETE FROM user_student
WHERE id_student = p_id_student; WHERE id_student = p_id_student;
-- Remove the student
DELETE FROM student DELETE FROM student
WHERE id_student = p_id_student; WHERE id_student = p_id_student;
SELECT ROW_COUNT() AS rows_affected; SELECT ROW_COUNT() AS rows_affected;
-7
View File
@@ -1,7 +0,0 @@
CREATE TABLE `school_district` (
`id_school_district` char(36) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`contact_email` varchar(255) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id_school_district`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE `student` (
`id_student` char(36) NOT NULL,
`id_program` char(36) DEFAULT NULL,
`identifier` varchar(50) DEFAULT NULL,
`program_year` int DEFAULT NULL,
`enrollment_date` date DEFAULT NULL,
`next_iep_date` date DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id_student`),
KEY `student_ibfk_1` (`id_program`),
CONSTRAINT `student_ibfk_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE `user` (
`id_user` char(36) NOT NULL,
`email` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`password_hash` varchar(255) DEFAULT NULL,
`password_salt` varchar(255) DEFAULT NULL,
`password_updated_at` timestamp NULL DEFAULT NULL,
`failed_login_attempts` int DEFAULT '0',
`locked_until` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-16
View File
@@ -1,16 +0,0 @@
CREATE TABLE `user_program` (
`id_user_program` char(36) NOT NULL,
`id_user` char(36) DEFAULT NULL,
`id_program` char(36) DEFAULT NULL,
`id_role` char(36) DEFAULT NULL,
`is_primary` tinyint(1) DEFAULT '0',
`status` varchar(20) DEFAULT 'active',
`joined_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id_user_program`),
UNIQUE KEY `uq_user_program` (`id_user`,`id_program`),
KEY `idx_id_program` (`id_program`),
KEY `idx_user_program_role` (`id_role`),
CONSTRAINT `user_program_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`),
CONSTRAINT `user_program_ibfk_2` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`),
CONSTRAINT `user_program_ibfk_3` FOREIGN KEY (`id_role`) REFERENCES `role` (`id_role`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-11
View File
@@ -1,11 +0,0 @@
CREATE TABLE `user_student` (
`id_user_student` char(36) NOT NULL,
`id_user` char(36) DEFAULT NULL,
`id_student` char(36) DEFAULT NULL,
`is_primary` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`id_user_student`),
KEY `user_student_ibfk_1` (`id_user`),
KEY `user_student_ibfk_2` (`id_student`),
CONSTRAINT `user_student_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`),
CONSTRAINT `user_student_ibfk_2` FOREIGN KEY (`id_student`) REFERENCES `student` (`id_student`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,19 @@
<app-modal-shell [title]="title()" (closed)="onCancel()">
@if (!awaitingSecondConfirm()) {
<p class="confirm-message">{{ message() }}</p>
} @else {
<p class="confirm-message second-confirm-message">Are you absolutely sure? This action is permanent and cannot be undone.</p>
}
<div class="modal-actions">
<button class="btn-secondary" (click)="onCancel()">{{ cancelLabel() }}</button>
@if (!awaitingSecondConfirm()) {
<button [class]="destructive() ? 'btn-danger' : 'btn-primary'" (click)="onConfirm()">
{{ confirmLabel() }}
</button>
} @else {
<button class="btn-danger-confirm" (click)="onConfirm()">
Yes, delete permanently
</button>
}
</div>
</app-modal-shell>
@@ -0,0 +1,42 @@
.confirm-message {
font-size: 14px;
line-height: 1.55;
color: var(--text-secondary);
margin: 0 0 16px;
}
.second-confirm-message {
color: #DC2626;
font-weight: 600;
}
:host ::ng-deep .btn-danger {
padding: 8px 20px;
border-radius: var(--radius-md);
border: none;
background: #DC2626;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
&:hover {
background: #B91C1C;
}
}
:host ::ng-deep .btn-danger-confirm {
padding: 8px 20px;
border-radius: var(--radius-md);
border: 2px solid #DC2626;
background: #FEF2F2;
color: #DC2626;
font-size: 13px;
font-weight: 600;
cursor: pointer;
&:hover {
background: #DC2626;
color: #fff;
}
}
@@ -0,0 +1,46 @@
import { Component, input, output, signal } from '@angular/core';
import { ModalShell } from '../modal-shell/modal-shell';
@Component({
selector: 'app-confirm-modal',
imports: [ModalShell],
templateUrl: './confirm-modal.html',
styleUrl: './confirm-modal.scss',
})
export class ConfirmModal {
// ************************** Declarations *************************
readonly title = input('Confirm');
readonly message = input.required<string>();
readonly confirmLabel = input('Delete');
readonly cancelLabel = input('Cancel');
readonly destructive = input(false);
readonly doubleConfirm = input(false);
readonly confirmed = output<void>();
readonly closed = output<void>();
// ************************** Properties ***************************
protected readonly awaitingSecondConfirm = signal(false);
// ************************ Event Handlers *************************
// *****************************************************************
// When doubleConfirm is enabled, the first click transitions to a
// second confirmation state. The second click emits confirmed.
// *****************************************************************
onConfirm() {
if (this.doubleConfirm() && !this.awaitingSecondConfirm()) {
this.awaitingSecondConfirm.set(true);
return;
}
this.confirmed.emit();
}
onCancel() {
this.awaitingSecondConfirm.set(false);
this.closed.emit();
}
}
@@ -23,12 +23,58 @@
[event]="showEditEventModal() === 'new' ? null : $any(showEditEventModal())" (saved)="onEventSaved()" [event]="showEditEventModal() === 'new' ? null : $any(showEditEventModal())" (saved)="onEventSaved()"
(closed)="showEditEventModal.set(null)" /> (closed)="showEditEventModal.set(null)" />
} }
@if (showDeleteConfirm()) {
<app-confirm-modal
title="Delete Goal"
[message]="'Delete \u0022' + selectedGoal()!.category + '\u0022 goal and all its benchmarks and progress events? This cannot be undone.'"
confirmLabel="Delete"
[destructive]="true"
[doubleConfirm]="true"
(confirmed)="onDeleteConfirmed()"
(closed)="showDeleteConfirm.set(false)" />
}
@if (showDeleteBenchmarkConfirm() && deletingBenchmark()) {
<app-confirm-modal
title="Delete Benchmark"
[message]="'Delete \u0022' + (deletingBenchmark()!.shortName || deletingBenchmark()!.benchmark) + '\u0022? This cannot be undone.'"
confirmLabel="Delete"
[destructive]="true"
(confirmed)="onDeleteBenchmarkConfirmed()"
(closed)="showDeleteBenchmarkConfirm.set(false)" />
}
@if (showDeleteEventConfirm() && deletingEvent()) {
<app-confirm-modal
title="Delete Progress Event"
message="Delete this progress event? This cannot be undone."
confirmLabel="Delete"
[destructive]="true"
(confirmed)="onDeleteEventConfirmed()"
(closed)="showDeleteEventConfirm.set(false)" />
}
@if (showDeleteStudentConfirm()) {
<app-confirm-modal
title="Delete Student"
[message]="'Delete \u0022' + student()!.identifier + '\u0022 and all their goals, benchmarks, and progress events? This cannot be undone.'"
confirmLabel="Delete"
[destructive]="true"
[doubleConfirm]="true"
(confirmed)="onDeleteStudentConfirmed()"
(closed)="showDeleteStudentConfirm.set(false)" />
}
<!-- Student Header --> <!-- Student Header -->
<div class="student-header"> <div class="student-header">
<div class="student-info"> <div class="student-info">
<h1 class="student-name">{{ student()!.identifier }}</h1> <h1 class="student-name">{{ student()!.identifier }}</h1>
<span class="student-iep">IEP {{ formatDate(student()!.nextIepDate) }}</span> <span class="student-iep">IEP {{ formatDate(student()!.nextIepDate) }}</span>
<button class="delete-student-btn" (click)="onDeleteStudent()" aria-label="Delete student" title="Delete student">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div> </div>
<div class="goal-tabs"> <div class="goal-tabs">
<button class="goal-tab add-goal" (click)="onAddGoal()">+ Goal</button> <button class="goal-tab add-goal" (click)="onAddGoal()">+ Goal</button>
@@ -47,11 +93,19 @@
<!-- Goal Card --> <!-- Goal Card -->
<div class="goal-card"> <div class="goal-card">
<div class="goal-card-header"> <div class="goal-card-header">
<span class="goal-badge">{{ selectedGoal()!.category }} Goal</span>
<app-edit-icon (click)="onEditGoal()" ariaLabel="Edit goal" /> <app-edit-icon (click)="onEditGoal()" ariaLabel="Edit goal" />
<span class="goal-badge">{{ selectedGoal()!.category }} Goal</span>
@if (selectedGoal()!.targetCompletionDate) { @if (selectedGoal()!.targetCompletionDate) {
<span class="goal-due">Due {{ formatDate(selectedGoal()!.targetCompletionDate) }}</span> <span class="goal-due">Due {{ formatDate(selectedGoal()!.targetCompletionDate) }}</span>
} }
<button class="delete-goal-btn" (click)="onDeleteGoal()" aria-label="Delete goal" title="Delete goal">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div> </div>
<p class="goal-description">{{ selectedGoal()!.description }}</p> <p class="goal-description">{{ selectedGoal()!.description }}</p>
</div> </div>
@@ -75,8 +129,16 @@
@for (b of goalBenchmarks(); track b.benchmarkId) { @for (b of goalBenchmarks(); track b.benchmarkId) {
<div class="benchmark-card"> <div class="benchmark-card">
<div class="benchmark-header"> <div class="benchmark-header">
<span class="benchmark-name">{{ b.shortName || b.benchmark }}</span>
<app-edit-icon size="13" (click)="onEditBenchmark(b)" ariaLabel="Edit benchmark" /> <app-edit-icon size="13" (click)="onEditBenchmark(b)" ariaLabel="Edit benchmark" />
<span class="benchmark-name">{{ b.shortName || b.benchmark }}</span>
<button class="delete-benchmark-btn" (click)="onDeleteBenchmark(b)" aria-label="Delete benchmark" title="Delete benchmark">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div> </div>
<p class="benchmark-desc">{{ b.benchmark }}</p> <p class="benchmark-desc">{{ b.benchmark }}</p>
</div> </div>
@@ -94,8 +156,16 @@
<div class="timeline-dot"></div> <div class="timeline-dot"></div>
<div class="event-card"> <div class="event-card">
<div class="event-header"> <div class="event-header">
<span class="event-date">{{ formatDate(ev.createdAt) }}</span>
<app-edit-icon size="13" (click)="onEditEvent(ev)" ariaLabel="Edit event" color="#bbb" /> <app-edit-icon size="13" (click)="onEditEvent(ev)" ariaLabel="Edit event" color="#bbb" />
<span class="event-date">{{ formatDate(ev.createdAt) }}</span>
<button class="delete-event-btn" (click)="onDeleteEvent(ev)" aria-label="Delete event" title="Delete event">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div> </div>
<p class="event-content">{{ ev.content }}</p> <p class="event-content">{{ ev.content }}</p>
@if (getBenchmarksForEvent(ev.progressEventId).length > 0) { @if (getBenchmarksForEvent(ev.progressEventId).length > 0) {
@@ -43,6 +43,26 @@
color: var(--text-faint); color: var(--text-faint);
} }
.delete-student-btn {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
align-self: center;
padding: 4px;
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
&:hover {
color: #DC2626;
background: #FEE2E2;
}
}
.goal-tabs { .goal-tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -117,6 +137,24 @@
margin-left: auto; margin-left: auto;
} }
.delete-goal-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
&:hover {
color: #DC2626;
background: #FEE2E2;
}
}
.goal-description { .goal-description {
font-size: 15px; font-size: 15px;
line-height: 1.55; line-height: 1.55;
@@ -193,6 +231,25 @@
margin: 0; margin: 0;
} }
.delete-benchmark-btn {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 4px;
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
&:hover {
color: #DC2626;
background: #FEE2E2;
}
}
/* ─── Progress Timeline ─── */ /* ─── Progress Timeline ─── */
.timeline { .timeline {
position: relative; position: relative;
@@ -251,6 +308,25 @@
color: #333; color: #333;
} }
.delete-event-btn {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 4px;
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
&:hover {
color: #DC2626;
background: #FEE2E2;
}
}
.event-benchmarks { .event-benchmarks {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -9,13 +9,14 @@ import { GoalModal } from '../goal-modal/goal-modal';
import { EditBenchmarkModal } from '../edit-benchmark-modal/edit-benchmark-modal'; import { EditBenchmarkModal } from '../edit-benchmark-modal/edit-benchmark-modal';
import { EditEventModal } from '../edit-event-modal/edit-event-modal'; import { EditEventModal } from '../edit-event-modal/edit-event-modal';
import { EditIcon } from '../edit-icon/edit-icon'; import { EditIcon } from '../edit-icon/edit-icon';
import { ConfirmModal } from '../confirm-modal/confirm-modal';
import { formatDate } from '../../../shared/utils/format-date'; import { formatDate } from '../../../shared/utils/format-date';
type TabView = 'benchmarks' | 'progress'; type TabView = 'benchmarks' | 'progress';
@Component({ @Component({
selector: 'app-workspace', selector: 'app-workspace',
imports: [GoalModal, EditBenchmarkModal, EditEventModal, EditIcon], imports: [GoalModal, EditBenchmarkModal, EditEventModal, EditIcon, ConfirmModal],
templateUrl: './workspace.html', templateUrl: './workspace.html',
styleUrl: './workspace.scss', styleUrl: './workspace.scss',
}) })
@@ -79,6 +80,12 @@ export class Workspace {
protected readonly showGoalModal = signal<StudentGoalItem | 'add' | null>(null); protected readonly showGoalModal = signal<StudentGoalItem | 'add' | null>(null);
protected readonly showEditBenchmarkModal = signal<BenchmarkDto | 'new' | null>(null); protected readonly showEditBenchmarkModal = signal<BenchmarkDto | 'new' | null>(null);
protected readonly showEditEventModal = signal<ProgressEventWithGoalDto | null | 'new'>(null); protected readonly showEditEventModal = signal<ProgressEventWithGoalDto | null | 'new'>(null);
protected readonly showDeleteConfirm = signal(false);
protected readonly showDeleteBenchmarkConfirm = signal(false);
protected readonly deletingBenchmark = signal<BenchmarkDto | null>(null);
protected readonly showDeleteEventConfirm = signal(false);
protected readonly deletingEvent = signal<ProgressEventWithGoalDto | null>(null);
protected readonly showDeleteStudentConfirm = signal(false);
// ************************** Properties *************************** // ************************** Properties ***************************
@@ -135,6 +142,28 @@ export class Workspace {
this.showGoalModal.set('add'); this.showGoalModal.set('add');
} }
onDeleteGoal() {
if (!this.selectedGoal()) return;
this.showDeleteConfirm.set(true);
}
// *****************************************************************
// Called when the user confirms deletion in the confirm modal.
// Deletes the selected goal and all its child entities.
// *****************************************************************
async onDeleteConfirmed() {
this.showDeleteConfirm.set(false);
const goal = this.selectedGoal();
if (!goal) return;
const result = await this.studentService.deleteGoal(this.studentId()!, goal.goalId);
if (!result.success) return;
this.selectedGoalId.set(null);
this.studentService.notifyDataChanged();
await this.refetchProfile();
}
onGoalCreated(goal: StudentGoalItem) { onGoalCreated(goal: StudentGoalItem) {
this.showGoalModal.set(null); this.showGoalModal.set(null);
this.studentService.notifyDataChanged(); this.studentService.notifyDataChanged();
@@ -156,6 +185,27 @@ export class Workspace {
this.showEditBenchmarkModal.set('new'); this.showEditBenchmarkModal.set('new');
} }
onDeleteBenchmark(b: BenchmarkDto) {
this.deletingBenchmark.set(b);
this.showDeleteBenchmarkConfirm.set(true);
}
// *****************************************************************
// Called when the user confirms deletion in the confirm modal.
// Deletes the benchmark and its event associations.
// *****************************************************************
async onDeleteBenchmarkConfirmed() {
this.showDeleteBenchmarkConfirm.set(false);
const b = this.deletingBenchmark();
if (!b) return;
this.deletingBenchmark.set(null);
const result = await this.studentService.deleteBenchmark(this.studentId()!, b.benchmarkId);
if (!result.success) return;
await this.refetchProfile();
}
onNewEvent() { onNewEvent() {
this.showEditEventModal.set('new'); this.showEditEventModal.set('new');
} }
@@ -169,6 +219,27 @@ export class Workspace {
this.refetchProfile(); this.refetchProfile();
} }
onDeleteEvent(ev: ProgressEventWithGoalDto) {
this.deletingEvent.set(ev);
this.showDeleteEventConfirm.set(true);
}
// *****************************************************************
// Called when the user confirms deletion in the confirm modal.
// Deletes the progress event and its benchmark associations.
// *****************************************************************
async onDeleteEventConfirmed() {
this.showDeleteEventConfirm.set(false);
const ev = this.deletingEvent();
if (!ev) return;
this.deletingEvent.set(null);
const result = await this.studentService.deleteProgressEvent(this.studentId()!, ev.progressEventId);
if (!result.success) return;
await this.refetchProfile();
}
// ***************************************************************** // *****************************************************************
// Returns the benchmark IDs associated with a given progress event, // Returns the benchmark IDs associated with a given progress event,
// read from the cached profile data. // read from the cached profile data.
@@ -186,6 +257,26 @@ export class Workspace {
formatDate = formatDate; formatDate = formatDate;
onDeleteStudent() {
this.showDeleteStudentConfirm.set(true);
}
// *****************************************************************
// Called when the user confirms student deletion. Deletes the
// student and navigates back to the home page.
// *****************************************************************
async onDeleteStudentConfirmed() {
this.showDeleteStudentConfirm.set(false);
const id = this.studentId();
if (!id) return;
const result = await this.studentService.deleteStudent(id);
if (!result.success) return;
this.studentService.notifyDataChanged();
this.router.navigate(['/']);
}
// ********************** Support Procedures *********************** // ********************** Support Procedures ***********************
private async loadStudentData(studentId: string) { private async loadStudentData(studentId: string) {
@@ -102,15 +102,11 @@
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--text-faint); color: #4338CA;
padding: 12px 12px 4px; background: #EEF2FF;
margin-top: 4px; padding: 6px 12px;
border-top: 1px solid var(--border-color); margin-top: 6px;
border-left: 3px solid #818CF8;
&:first-child {
border-top: none;
margin-top: 0;
}
} }
.student-item { .student-item {
@@ -145,6 +145,23 @@ export class StudentService {
} }
} }
// *****************************************************************
// Deletes a goal and all its child entities (benchmarks, progress
// events, event-benchmark links).
// *****************************************************************
async deleteGoal(studentId: string, goalId: string): Promise<ApiResult> {
try {
const result = await firstValueFrom(
this.http.delete<ResponseResult<void>>(`${this.base}/api/Student/${studentId}/goals/${goalId}`)
);
return result.success
? ApiResult.empty()
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// ***************************************************************** // *****************************************************************
// Creates a new progress event, optionally with benchmark // Creates a new progress event, optionally with benchmark
// associations. Returns the new progress event ID on success. // associations. Returns the new progress event ID on success.
@@ -178,6 +195,22 @@ export class StudentService {
} }
} }
// *****************************************************************
// Deletes a progress event and its benchmark associations.
// *****************************************************************
async deleteProgressEvent(studentId: string, progressEventId: string): Promise<ApiResult> {
try {
const result = await firstValueFrom(
this.http.delete<ResponseResult<void>>(`${this.base}/api/Student/${studentId}/progress-events/${progressEventId}`)
);
return result.success
? ApiResult.empty()
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// ***************************************************************** // *****************************************************************
// Returns a full progress report for a student within a date // Returns a full progress report for a student within a date
// range, including goals, events, and benchmark associations. // range, including goals, events, and benchmark associations.
@@ -221,6 +254,22 @@ export class StudentService {
} }
} }
// *****************************************************************
// Deletes a student and all associated data.
// *****************************************************************
async deleteStudent(studentId: string): Promise<ApiResult> {
try {
const result = await firstValueFrom(
this.http.delete<ResponseResult<void>>(`${this.base}/api/Student/${studentId}`)
);
return result.success
? ApiResult.empty()
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// ***************************************************************** // *****************************************************************
// Returns benchmarks for a given student. // Returns benchmarks for a given student.
// ***************************************************************** // *****************************************************************
@@ -269,6 +318,22 @@ export class StudentService {
} }
} }
// *****************************************************************
// Deletes a benchmark and its progress-event associations.
// *****************************************************************
async deleteBenchmark(studentId: string, benchmarkId: string): Promise<ApiResult> {
try {
const result = await firstValueFrom(
this.http.delete<ResponseResult<void>>(`${this.base}/api/Student/${studentId}/benchmarks/${benchmarkId}`)
);
return result.success
? ApiResult.empty()
: ApiResult.fail(result.message);
} catch (error) {
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
}
}
// ***************************************************************** // *****************************************************************
// Requests an AI-generated benchmark recommendation for a goal. // Requests an AI-generated benchmark recommendation for a goal.
// ***************************************************************** // *****************************************************************