mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 02:57:36 +00:00
Updates to encompass benchmarks
This commit is contained in:
@@ -130,6 +130,39 @@ public class StudentController : BaseController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{idStudent:guid}/benchmarks")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkSummary>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkSummary>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ResponseResult<StudentBenchmarkSummary>>> GetBenchmarks(Guid idStudent)
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
|
||||||
|
|
||||||
|
if (!students.Select(s => s.StudentId).Contains(idStudent))
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<StudentBenchmarkSummary>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = await _studentRepository.GetBenchmarkSummaryAsync(idStudent);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<StudentBenchmarkSummary>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Benchmarks retrieved successfully.",
|
||||||
|
Data = summary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{idStudent:guid}/goals")]
|
[HttpPost("{idStudent:guid}/goals")]
|
||||||
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
|
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
|
||||||
[ProducesResponseType(typeof(ResponseResult<StudentGoalItem>), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(ResponseResult<StudentGoalItem>), StatusCodes.Status201Created)]
|
||||||
@@ -205,6 +238,38 @@ public class StudentController : BaseController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{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>>> UpdateGoal(Guid idStudent, Guid idGoal, [FromBody] UpdateGoalDto dto)
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
|
||||||
|
|
||||||
|
if (!students.Select(s => s.StudentId).Contains(idStudent))
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = await _studentRepository.UpdateGoalAsync(idGoal, dto);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = updated ? "Goal updated successfully." : "No changes were applied."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[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)]
|
||||||
@@ -411,4 +476,87 @@ public class StudentController : BaseController
|
|||||||
Message = "Student deleted."
|
Message = "Student deleted."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{idStudent:guid}/benchmarks")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkItem>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkItem>), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<StudentBenchmarkItem>), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<ResponseResult<StudentBenchmarkItem>>> CreateBenchmark(Guid idStudent, [FromBody] CreateBenchmarkDto dto)
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
|
||||||
|
|
||||||
|
if (!students.Select(s => s.StudentId).Contains(idStudent))
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<StudentBenchmarkItem>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PermissionService.IsAllowed(role, EntityType.Benchmark, PermissionAction.Create, isMine: true))
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<StudentBenchmarkItem>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Unable to create benchmark. - Permission Matrix"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = await _studentRepository.InsertBenchmarkAsync(dto.GoalId, userId, dto);
|
||||||
|
if (created is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new ResponseResult<StudentBenchmarkItem>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Unable to create benchmark."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatusCode(StatusCodes.Status201Created, new ResponseResult<StudentBenchmarkItem>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Benchmark created successfully.",
|
||||||
|
Data = created
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{idStudent:guid}/benchmarks/{idBenchmark:guid}")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.Teacher}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ResponseResult<object>>> UpdateBenchmark(Guid idStudent, Guid idBenchmark, [FromBody] UpdateBenchmarkDto dto)
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
|
||||||
|
|
||||||
|
if (!students.Select(s => s.StudentId).Contains(idStudent))
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = await _studentRepository.UpdateBenchmarkAsync(idBenchmark, dto.Benchmark);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = updated ? "Changes applied successfully." : "No changes were applied."
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class CreateBenchmarkDto
|
||||||
|
{
|
||||||
|
public Guid GoalId { get; set; }
|
||||||
|
public string Benchmark { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class UpdateBenchmarkDto
|
||||||
|
{
|
||||||
|
public string Benchmark { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class UpdateGoalDto
|
||||||
|
{
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Category { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class dbStudentBenchmarkRow
|
||||||
|
{
|
||||||
|
public string? StudentIdentifier { get; set; }
|
||||||
|
public required Guid BenchmarkId { get; set; }
|
||||||
|
public required Guid GoalId { get; set; }
|
||||||
|
public string? GoalTitle { get; set; }
|
||||||
|
public string? Benchmark { get; set; }
|
||||||
|
public string? CreatedByName { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@ public class dbStudentGoalRow
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
public int ProgressEventCount { get; set; }
|
public int ProgressEventCount { get; set; }
|
||||||
|
public int BenchmarkCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,9 +194,119 @@ public class StudentRepository
|
|||||||
Title = r.Title,
|
Title = r.Title,
|
||||||
Description = r.Description,
|
Description = r.Description,
|
||||||
Category = r.Category,
|
Category = r.Category,
|
||||||
ProgressEventCount = r.ProgressEventCount
|
ProgressEventCount = r.ProgressEventCount,
|
||||||
|
BenchmarkCount = r.BenchmarkCount
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Updates a goal's title, description, and category.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<bool> UpdateGoalAsync(Guid goalId, UpdateGoalDto dto)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var rowsAffected = await db.ExecuteScalarAsync<int>(
|
||||||
|
"sp_Goal_Update",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_goal = goalId.ToString(),
|
||||||
|
p_id_goal_parent = (string?)null,
|
||||||
|
p_id_student = (string?)null,
|
||||||
|
p_id_user_created = (string?)null,
|
||||||
|
p_title = dto.Title,
|
||||||
|
p_description = dto.Description,
|
||||||
|
p_category = dto.Category
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rowsAffected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns all benchmarks for a student, grouped under a summary
|
||||||
|
// with the student identifier. Returns null if student not found.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<StudentBenchmarkSummary?> GetBenchmarkSummaryAsync(Guid idStudent)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var rows = await db.QueryAsync<dbStudentBenchmarkRow>(
|
||||||
|
"sp_Benchmark_GetByStudentId",
|
||||||
|
new { p_id_student = idStudent.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
var list = rows.ToList();
|
||||||
|
if (list.Count == 0)
|
||||||
|
{
|
||||||
|
var student = await GetByIdAsync(idStudent);
|
||||||
|
if (student is null) return null;
|
||||||
|
|
||||||
|
return new StudentBenchmarkSummary
|
||||||
|
{
|
||||||
|
StudentIdentifier = student.Identifier,
|
||||||
|
Benchmarks = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StudentBenchmarkSummary
|
||||||
|
{
|
||||||
|
StudentIdentifier = list[0].StudentIdentifier,
|
||||||
|
Benchmarks = list.Select(r => new StudentBenchmarkItem
|
||||||
|
{
|
||||||
|
BenchmarkId = r.BenchmarkId,
|
||||||
|
GoalId = r.GoalId,
|
||||||
|
GoalTitle = r.GoalTitle,
|
||||||
|
Benchmark = r.Benchmark,
|
||||||
|
CreatedByName = r.CreatedByName,
|
||||||
|
CreatedAt = r.CreatedAt,
|
||||||
|
UpdatedAt = r.UpdatedAt
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Inserts a new benchmark and returns the created benchmark item.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<StudentBenchmarkItem?> InsertBenchmarkAsync(Guid goalId, Guid userId, CreateBenchmarkDto dto)
|
||||||
|
{
|
||||||
|
var newId = Guid.NewGuid();
|
||||||
|
using var db = Connection;
|
||||||
|
var row = await db.QuerySingleOrDefaultAsync(
|
||||||
|
"sp_Benchmark_Insert",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_benchmark = newId.ToString(),
|
||||||
|
p_id_goal = goalId.ToString(),
|
||||||
|
p_id_user_created = userId.ToString(),
|
||||||
|
p_benchmark = dto.Benchmark
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
if (row is null) return null;
|
||||||
|
|
||||||
|
return new StudentBenchmarkItem
|
||||||
|
{
|
||||||
|
BenchmarkId = newId,
|
||||||
|
GoalId = goalId,
|
||||||
|
Benchmark = dto.Benchmark,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Updates a benchmark's text and returns whether rows were affected.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<bool> UpdateBenchmarkAsync(Guid benchmarkId, string benchmarkText)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
var rowsAffected = await db.ExecuteScalarAsync<int>(
|
||||||
|
"sp_Benchmark_Update",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
p_id_benchmark = benchmarkId.ToString(),
|
||||||
|
p_benchmark = benchmarkText
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rowsAffected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace WinStudentGoalTracker.Models;
|
||||||
|
|
||||||
|
public class StudentBenchmarkItem
|
||||||
|
{
|
||||||
|
public Guid BenchmarkId { get; set; }
|
||||||
|
public Guid GoalId { get; set; }
|
||||||
|
public string? GoalTitle { get; set; }
|
||||||
|
public string? Benchmark { get; set; }
|
||||||
|
public string? CreatedByName { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace WinStudentGoalTracker.Models;
|
||||||
|
|
||||||
|
public class StudentBenchmarkSummary
|
||||||
|
{
|
||||||
|
public string? StudentIdentifier { get; set; }
|
||||||
|
public List<StudentBenchmarkItem> Benchmarks { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -8,4 +8,5 @@ public class StudentGoalItem
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public string? Category { get; set; }
|
public string? Category { get; set; }
|
||||||
public int ProgressEventCount { get; set; }
|
public int ProgressEventCount { get; set; }
|
||||||
|
public int BenchmarkCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ public static class EntityType
|
|||||||
public const string Student = "student";
|
public const string Student = "student";
|
||||||
public const string Goal = "goal";
|
public const string Goal = "goal";
|
||||||
public const string ProgressEvent = "progress_event";
|
public const string ProgressEvent = "progress_event";
|
||||||
|
public const string Benchmark = "benchmark";
|
||||||
|
|
||||||
public static string? TryParse(string value) =>
|
public static string? TryParse(string value) =>
|
||||||
All.Contains(value) ? value : null;
|
All.Contains(value) ? value : null;
|
||||||
|
|
||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
[SchoolDistrict, Program, User, Student, Goal, ProgressEvent];
|
[SchoolDistrict, Program, User, Student, Goal, ProgressEvent, Benchmark];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ public static class PermissionMatrix
|
|||||||
[PermissionAction.Update] = Allow,
|
[PermissionAction.Update] = Allow,
|
||||||
[PermissionAction.Delete] = Allow,
|
[PermissionAction.Delete] = Allow,
|
||||||
},
|
},
|
||||||
|
[EntityType.Benchmark] = new()
|
||||||
|
{
|
||||||
|
[PermissionAction.Create] = Allow,
|
||||||
|
[PermissionAction.Read] = Allow,
|
||||||
|
[PermissionAction.Update] = Allow,
|
||||||
|
[PermissionAction.Delete] = Allow,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -109,6 +116,13 @@ public static class PermissionMatrix
|
|||||||
[PermissionAction.Update] = Allow,
|
[PermissionAction.Update] = Allow,
|
||||||
[PermissionAction.Delete] = Allow,
|
[PermissionAction.Delete] = Allow,
|
||||||
},
|
},
|
||||||
|
[EntityType.Benchmark] = new()
|
||||||
|
{
|
||||||
|
[PermissionAction.Create] = Allow,
|
||||||
|
[PermissionAction.Read] = Allow,
|
||||||
|
[PermissionAction.Update] = Allow,
|
||||||
|
[PermissionAction.Delete] = Allow,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -158,6 +172,13 @@ public static class PermissionMatrix
|
|||||||
[PermissionAction.Update] = Allow,
|
[PermissionAction.Update] = Allow,
|
||||||
[PermissionAction.Delete] = MineOnly,
|
[PermissionAction.Delete] = MineOnly,
|
||||||
},
|
},
|
||||||
|
[EntityType.Benchmark] = new()
|
||||||
|
{
|
||||||
|
[PermissionAction.Create] = Allow,
|
||||||
|
[PermissionAction.Read] = Allow,
|
||||||
|
[PermissionAction.Update] = Allow,
|
||||||
|
[PermissionAction.Delete] = MineOnly,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -207,6 +228,13 @@ public static class PermissionMatrix
|
|||||||
[PermissionAction.Update] = MineOnly,
|
[PermissionAction.Update] = MineOnly,
|
||||||
[PermissionAction.Delete] = MineOnly,
|
[PermissionAction.Delete] = MineOnly,
|
||||||
},
|
},
|
||||||
|
[EntityType.Benchmark] = new()
|
||||||
|
{
|
||||||
|
[PermissionAction.Create] = MineOnly,
|
||||||
|
[PermissionAction.Read] = MineOnly,
|
||||||
|
[PermissionAction.Update] = MineOnly,
|
||||||
|
[PermissionAction.Delete] = MineOnly,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -256,6 +284,13 @@ public static class PermissionMatrix
|
|||||||
[PermissionAction.Update] = MineOnly,
|
[PermissionAction.Update] = MineOnly,
|
||||||
[PermissionAction.Delete] = Deny,
|
[PermissionAction.Delete] = Deny,
|
||||||
},
|
},
|
||||||
|
[EntityType.Benchmark] = new()
|
||||||
|
{
|
||||||
|
[PermissionAction.Create] = Deny,
|
||||||
|
[PermissionAction.Read] = MineOnly,
|
||||||
|
[PermissionAction.Update] = Deny,
|
||||||
|
[PermissionAction.Delete] = Deny,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Benchmark_GetByStudentId`(IN p_id_student CHAR(36))
|
||||||
|
BEGIN
|
||||||
|
SELECT
|
||||||
|
s.`identifier` AS `studentIdentifier`,
|
||||||
|
b.`id_benchmark` AS `benchmarkId`,
|
||||||
|
b.`id_goal` AS `goalId`,
|
||||||
|
g.`title` AS `goalTitle`,
|
||||||
|
b.`benchmark` AS `benchmark`,
|
||||||
|
u.`name` AS `createdByName`,
|
||||||
|
b.`created_at` AS `createdAt`,
|
||||||
|
b.`updated_at` AS `updatedAt`
|
||||||
|
FROM `benchmark` b
|
||||||
|
INNER JOIN `goal` g ON g.`id_goal` = b.`id_goal`
|
||||||
|
INNER JOIN `student` s ON s.`id_student` = g.`id_student`
|
||||||
|
LEFT JOIN `user` u ON u.`id_user` = b.`id_user_created`
|
||||||
|
WHERE g.`id_student` = p_id_student
|
||||||
|
ORDER BY b.`created_at` DESC;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Benchmark_Insert`(
|
||||||
|
IN p_id_benchmark CHAR(36),
|
||||||
|
IN p_id_goal CHAR(36),
|
||||||
|
IN p_id_user_created CHAR(36),
|
||||||
|
IN p_benchmark TEXT
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO benchmark
|
||||||
|
(
|
||||||
|
id_benchmark,
|
||||||
|
id_goal,
|
||||||
|
id_user_created,
|
||||||
|
benchmark,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
p_id_benchmark,
|
||||||
|
p_id_goal,
|
||||||
|
p_id_user_created,
|
||||||
|
p_benchmark,
|
||||||
|
UTC_TIMESTAMP(),
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
SELECT
|
||||||
|
id_benchmark,
|
||||||
|
id_goal,
|
||||||
|
id_user_created,
|
||||||
|
benchmark,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM benchmark
|
||||||
|
WHERE id_benchmark = p_id_benchmark
|
||||||
|
LIMIT 1;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Benchmark_Update`(
|
||||||
|
IN p_id_benchmark CHAR(36),
|
||||||
|
IN p_benchmark TEXT
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
UPDATE benchmark
|
||||||
|
SET
|
||||||
|
benchmark = p_benchmark,
|
||||||
|
updated_at = UTC_TIMESTAMP()
|
||||||
|
WHERE id_benchmark = p_id_benchmark;
|
||||||
|
SELECT ROW_COUNT() AS rowsAffected;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
@@ -8,7 +8,8 @@ BEGIN
|
|||||||
vc.`title`,
|
vc.`title`,
|
||||||
vc.`description`,
|
vc.`description`,
|
||||||
vc.`category`,
|
vc.`category`,
|
||||||
vc.`progressEventCount`
|
vc.`progressEventCount`,
|
||||||
|
vc.`benchmarkCount`
|
||||||
FROM `v_goal_card` vc
|
FROM `v_goal_card` vc
|
||||||
INNER JOIN `student` s ON s.`id_student` = vc.`studentId`
|
INNER JOIN `student` s ON s.`id_student` = vc.`studentId`
|
||||||
WHERE vc.`studentId` = p_id_student
|
WHERE vc.`studentId` = p_id_student
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `benchmark` (
|
||||||
|
`id_benchmark` char(36) NOT NULL DEFAULT (uuid()),
|
||||||
|
`id_goal` char(36) NOT NULL,
|
||||||
|
`id_user_created` char(36) NOT NULL,
|
||||||
|
`benchmark` text NOT NULL,
|
||||||
|
`created_at` datetime NOT NULL,
|
||||||
|
`updated_at` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id_benchmark`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE `progress_event_benchmark` (
|
||||||
|
`id_progress_event_benchmark` char(36) NOT NULL DEFAULT (uuid()),
|
||||||
|
`id_progress_event` char(36) NOT NULL,
|
||||||
|
`id_benchmark` char(36) NOT NULL,
|
||||||
|
`created_at` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (`id_progress_event_benchmark`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstudentgoaltracker`.`v_goal_card` AS
|
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstudentgoaltracker`.`v_goal_card` AS
|
||||||
select `winstudentgoaltracker`.`goal`.`id_goal` AS `goalId`,`winstudentgoaltracker`.`goal`.`id_goal_parent` AS `goalParentId`,`winstudentgoaltracker`.`goal`.`id_student` AS `studentId`,`winstudentgoaltracker`.`goal`.`title` AS `title`,`winstudentgoaltracker`.`goal`.`description` AS `description`,`winstudentgoaltracker`.`goal`.`category` AS `category`,count(`pe`.`id_progress_event`) AS `progressEventCount`
|
select `g`.`id_goal` AS `goalId`,`g`.`id_goal_parent` AS `goalParentId`,`g`.`id_student` AS `studentId`,`g`.`title` AS `title`,`g`.`description` AS `description`,`g`.`category` AS `category`,count(distinct `pe`.`id_progress_event`) AS `progressEventCount`,count(distinct `b`.`id_benchmark`) AS `benchmarkCount`
|
||||||
from (`winstudentgoaltracker`.`goal`
|
from ((`winstudentgoaltracker`.`goal` `g`
|
||||||
left
|
left
|
||||||
join `winstudentgoaltracker`.`progress_event` `pe` on((`pe`.`id_goal` = `winstudentgoaltracker`.`goal`.`id_goal`))) group by `winstudentgoaltracker`.`goal`.`id_goal`,`winstudentgoaltracker`.`goal`.`id_goal_parent`,`winstudentgoaltracker`.`goal`.`id_student`,`winstudentgoaltracker`.`goal`.`title`,`winstudentgoaltracker`.`goal`.`description`,`winstudentgoaltracker`.`goal`.`category`;
|
join `winstudentgoaltracker`.`progress_event` `pe` on((`pe`.`id_goal` = `g`.`id_goal`)))
|
||||||
|
left
|
||||||
|
join `winstudentgoaltracker`.`benchmark` `b` on((`b`.`id_goal` = `g`.`id_goal`))) group by `g`.`id_goal`,`g`.`id_goal_parent`,`g`.`id_student`,`g`.`title`,`g`.`description`,`g`.`category`;
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `winstud
|
|||||||
select `pe`.`id_progress_event` AS `progressEventId`,`pe`.`id_goal` AS `goalId`,`g`.`id_student` AS `studentId`,`pe`.`content` AS `content`,`pe`.`created_at` AS `createdAt`,`u`.`name` AS `createdByName`
|
select `pe`.`id_progress_event` AS `progressEventId`,`pe`.`id_goal` AS `goalId`,`g`.`id_student` AS `studentId`,`pe`.`content` AS `content`,`pe`.`created_at` AS `createdAt`,`u`.`name` AS `createdByName`
|
||||||
from ((`winstudentgoaltracker`.`progress_event` `pe`
|
from ((`winstudentgoaltracker`.`progress_event` `pe`
|
||||||
join `winstudentgoaltracker`.`goal` `g` on((`g`.`id_goal` = `pe`.`id_goal`)))
|
join `winstudentgoaltracker`.`goal` `g` on((`g`.`id_goal` = `pe`.`id_goal`)))
|
||||||
left join `winstudentgoaltracker`.`user` `u` on((`u`.`id_user` = `pe`.`id_user_created`)));
|
left
|
||||||
|
join `winstudentgoaltracker`.`user` `u` on((`u`.`id_user` = `pe`.`id_user_created`)));
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<div class="toolbar">
|
||||||
|
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Benchmarks</button>
|
||||||
|
<span class="toolbar-title">{{ isNew() ? 'New Benchmark' : 'Benchmark Detail' }}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<p class="error">{{ errorMessage() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (successMessage()) {
|
||||||
|
<p class="success">{{ successMessage() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loaded()) {
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Goal</span>
|
||||||
|
<span class="field-value">{{ goalTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="benchmarkText">Benchmark</label>
|
||||||
|
<textarea id="benchmarkText" class="field-input field-textarea" [(ngModel)]="benchmarkText" rows="4"
|
||||||
|
placeholder="Enter benchmark text..."></textarea>
|
||||||
|
</div>
|
||||||
|
@if (!isNew()) {
|
||||||
|
<div class="metadata">
|
||||||
|
@if (createdByName) {
|
||||||
|
<span class="meta-item">Created by: {{ createdByName }}</span>
|
||||||
|
}
|
||||||
|
<span class="meta-item">Created: {{ createdAt | date:'medium' }}</span>
|
||||||
|
@if (updatedAt) {
|
||||||
|
<span class="meta-item">Updated: {{ updatedAt | date:'medium' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
|
||||||
|
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
|
||||||
|
{{ saving() ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 40px;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #4f46e5;
|
||||||
|
border: 1px solid #4f46e5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #dc2626;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #16a34a;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions .toolbar-btn {
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { BenchmarkCardFull } from './benchmark-card-full';
|
||||||
|
|
||||||
|
describe('BenchmarkCardFull', () => {
|
||||||
|
let component: BenchmarkCardFull;
|
||||||
|
let fixture: ComponentFixture<BenchmarkCardFull>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [BenchmarkCardFull]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(BenchmarkCardFull);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
import { Component, inject, signal, OnDestroy } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { StudentService } from '../../../shared/services/student.service';
|
||||||
|
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-benchmark-card-full',
|
||||||
|
imports: [FormsModule, DatePipe],
|
||||||
|
templateUrl: './benchmark-card-full.html',
|
||||||
|
styleUrl: './benchmark-card-full.scss',
|
||||||
|
})
|
||||||
|
export class BenchmarkCardFull implements OnDestroy {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.paramSub = this.route.paramMap.subscribe(params => {
|
||||||
|
this.studentId = params.get('studentId')!;
|
||||||
|
this.goalId = params.get('goalId')!;
|
||||||
|
this.benchmarkId = params.get('benchmarkId') ?? null;
|
||||||
|
this.loadBenchmark();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
private readonly studentService = inject(StudentService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly paramSub: Subscription;
|
||||||
|
|
||||||
|
private studentId!: string;
|
||||||
|
private goalId!: string;
|
||||||
|
private benchmarkId: string | null = null;
|
||||||
|
|
||||||
|
protected readonly loaded = signal(false);
|
||||||
|
protected readonly isNew = signal(false);
|
||||||
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly successMessage = signal<string | null>(null);
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
|
||||||
|
// Form field
|
||||||
|
protected benchmarkText = '';
|
||||||
|
private savedBenchmarkText = '';
|
||||||
|
|
||||||
|
// Read-only metadata
|
||||||
|
protected goalTitle = '';
|
||||||
|
protected createdByName = '';
|
||||||
|
protected createdAt: Date | null = null;
|
||||||
|
protected updatedAt: Date | null = null;
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns true if the benchmark text has unsaved changes.
|
||||||
|
// *****************************************************************
|
||||||
|
hasChanges(): boolean {
|
||||||
|
return this.benchmarkText !== this.savedBenchmarkText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Saves changes or creates a new benchmark.
|
||||||
|
// *****************************************************************
|
||||||
|
async onSave() {
|
||||||
|
this.saving.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.successMessage.set(null);
|
||||||
|
|
||||||
|
if (this.isNew()) {
|
||||||
|
const result = await this.studentService.createBenchmark(this.studentId, {
|
||||||
|
goalId: this.goalId,
|
||||||
|
benchmark: this.benchmarkText,
|
||||||
|
});
|
||||||
|
this.saving.set(false);
|
||||||
|
if (result.success) {
|
||||||
|
this.successMessage.set('Benchmark created.');
|
||||||
|
this.savedBenchmarkText = this.benchmarkText;
|
||||||
|
if (result.payload?.benchmarkId) {
|
||||||
|
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'benchmarks', result.payload.benchmarkId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(result.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await this.studentService.updateBenchmark(this.studentId, this.benchmarkId!, this.benchmarkText);
|
||||||
|
this.saving.set(false);
|
||||||
|
if (result.success) {
|
||||||
|
this.savedBenchmarkText = this.benchmarkText;
|
||||||
|
this.successMessage.set('Changes saved.');
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Reverts the benchmark text to the last-saved value.
|
||||||
|
// *****************************************************************
|
||||||
|
onCancel() {
|
||||||
|
this.benchmarkText = this.savedBenchmarkText;
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.successMessage.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBack() {
|
||||||
|
this.router.navigate(['/students', this.studentId, 'benchmarks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.paramSub.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Loads existing benchmark data or sets up new-benchmark state.
|
||||||
|
// *****************************************************************
|
||||||
|
private loadBenchmark() {
|
||||||
|
if (!this.benchmarkId) {
|
||||||
|
this.isNew.set(true);
|
||||||
|
this.benchmarkText = '';
|
||||||
|
this.savedBenchmarkText = '';
|
||||||
|
this.loadGoalTitle();
|
||||||
|
this.loaded.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isNew.set(false);
|
||||||
|
this.studentService.getBenchmarksForStudent(this.studentId).then(result => {
|
||||||
|
if (!result.success || !result.payload) {
|
||||||
|
this.errorMessage.set(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bm = result.payload.benchmarks.find(b => b.benchmarkId === this.benchmarkId);
|
||||||
|
if (!bm) {
|
||||||
|
this.errorMessage.set('Benchmark not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.benchmarkText = bm.benchmark;
|
||||||
|
this.savedBenchmarkText = bm.benchmark;
|
||||||
|
this.goalTitle = bm.goalTitle;
|
||||||
|
this.createdByName = bm.createdByName;
|
||||||
|
this.createdAt = bm.createdAt;
|
||||||
|
this.updatedAt = bm.updatedAt;
|
||||||
|
this.loaded.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Loads the goal title for a new benchmark.
|
||||||
|
// *****************************************************************
|
||||||
|
private loadGoalTitle() {
|
||||||
|
this.studentService.getGoalsForStudent(this.studentId).then(result => {
|
||||||
|
if (result.success && result.payload) {
|
||||||
|
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
|
||||||
|
this.goalTitle = goal?.title ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="goal-badge">{{ benchmark().goalTitle }}</span>
|
||||||
|
@if (benchmark().updatedAt) {
|
||||||
|
<span class="date">Updated: {{ benchmark().updatedAt | date:'M/d/yy' }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="date">{{ benchmark().createdAt | date:'M/d/yy' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="benchmark-text">{{ benchmark().benchmark }}</p>
|
||||||
|
</div>
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-badge {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #16a34a;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benchmark-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { BenchmarkCard } from './benchmark-card';
|
||||||
|
|
||||||
|
describe('BenchmarkCard', () => {
|
||||||
|
let component: BenchmarkCard;
|
||||||
|
let fixture: ComponentFixture<BenchmarkCard>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [BenchmarkCard]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(BenchmarkCard);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-benchmark-card',
|
||||||
|
imports: [DatePipe],
|
||||||
|
templateUrl: './benchmark-card.html',
|
||||||
|
styleUrl: './benchmark-card.scss',
|
||||||
|
})
|
||||||
|
export class BenchmarkCard {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
readonly benchmark = input.required<BenchmarkDto>();
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<div class="toolbar">
|
||||||
|
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Goal</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="toolbar-btn" (click)="onAddBenchmark()">+ Add a Benchmark</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (studentIdentifier()) {
|
||||||
|
<h2 class="section-header">Benchmarks for {{ studentIdentifier() }}</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<p class="error">{{ errorMessage() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (benchmarks().length === 0 && !errorMessage()) {
|
||||||
|
<p class="empty-state">No benchmarks yet. Click <a class="empty-link" (click)="onAddBenchmark()">Add a Benchmark</a> to
|
||||||
|
get started.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="card-grid">
|
||||||
|
@for (bm of benchmarks(); track bm.benchmarkId) {
|
||||||
|
<app-benchmark-card [benchmark]="bm" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 40px;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #4f46e5;
|
||||||
|
border: 1px solid #4f46e5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #dc2626;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-link {
|
||||||
|
color: #4f46e5;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { BenchmarkList } from './benchmark-list';
|
||||||
|
|
||||||
|
describe('BenchmarkList', () => {
|
||||||
|
let component: BenchmarkList;
|
||||||
|
let fixture: ComponentFixture<BenchmarkList>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [BenchmarkList]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(BenchmarkList);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
|
||||||
|
import { StudentService } from '../../../shared/services/student.service';
|
||||||
|
import { BenchmarkCard } from '../benchmark-card/benchmark-card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-benchmark-list',
|
||||||
|
imports: [BenchmarkCard],
|
||||||
|
templateUrl: './benchmark-list.html',
|
||||||
|
styleUrl: './benchmark-list.scss',
|
||||||
|
})
|
||||||
|
export class BenchmarkList {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
|
||||||
|
this.goalId = this.route.snapshot.paramMap.get('goalId') || '';
|
||||||
|
this.loadBenchmarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
private readonly studentService = inject(StudentService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
protected readonly studentId: string;
|
||||||
|
protected readonly goalId: string;
|
||||||
|
protected readonly studentIdentifier = signal<string | null>(null);
|
||||||
|
protected readonly benchmarks = signal<BenchmarkDto[]>([]);
|
||||||
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
onAddBenchmark() {
|
||||||
|
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'benchmarks', 'new']);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBack() {
|
||||||
|
this.router.navigate(['/students', this.studentId, 'goals', this.goalId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Loads benchmarks for the student from the service.
|
||||||
|
// *****************************************************************
|
||||||
|
private loadBenchmarks() {
|
||||||
|
this.studentService.getBenchmarksForStudent(this.studentId).then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
this.errorMessage.set(data.message);
|
||||||
|
} else {
|
||||||
|
this.studentIdentifier.set(data.payload?.studentIdentifier ?? null);
|
||||||
|
this.benchmarks.set(data.payload?.benchmarks ?? []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="toolbar">
|
||||||
|
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Student</button>
|
||||||
|
<span class="toolbar-title">Goal Detail</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<p class="error">{{ errorMessage() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (successMessage()) {
|
||||||
|
<p class="success">{{ successMessage() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loaded()) {
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="title">Title</label>
|
||||||
|
<input id="title" class="field-input" type="text" [(ngModel)]="title" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="description">Description</label>
|
||||||
|
<textarea id="description" class="field-input field-textarea" [(ngModel)]="description" rows="4"
|
||||||
|
placeholder="Enter description..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="category">Category</label>
|
||||||
|
<input id="category" class="field-input" type="text" [(ngModel)]="category" />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
|
||||||
|
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
|
||||||
|
{{ saving() ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="detail-link" (click)="onProgressEvents()">Progress Events</a>
|
||||||
|
<a class="detail-link" (click)="onBenchmarks()">Benchmarks</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 40px;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #4f46e5;
|
||||||
|
border: 1px solid #4f46e5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #dc2626;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #16a34a;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4f46e5;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link:hover {
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions .toolbar-btn {
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GoalCardFull } from './goal-card-full';
|
||||||
|
|
||||||
|
describe('GoalCardFull', () => {
|
||||||
|
let component: GoalCardFull;
|
||||||
|
let fixture: ComponentFixture<GoalCardFull>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GoalCardFull]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GoalCardFull);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Component, inject, signal, OnDestroy } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { StudentService } from '../../../shared/services/student.service';
|
||||||
|
import { StudentGoalItem } from '../../../shared/classes/student-goal';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-goal-card-full',
|
||||||
|
imports: [FormsModule],
|
||||||
|
templateUrl: './goal-card-full.html',
|
||||||
|
styleUrl: './goal-card-full.scss',
|
||||||
|
})
|
||||||
|
export class GoalCardFull implements OnDestroy {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.paramSub = this.route.paramMap.subscribe(params => {
|
||||||
|
this.studentId = params.get('studentId')!;
|
||||||
|
this.goalId = params.get('goalId')!;
|
||||||
|
this.loadGoal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
private readonly studentService = inject(StudentService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly paramSub: Subscription;
|
||||||
|
|
||||||
|
private studentId!: string;
|
||||||
|
private goalId!: string;
|
||||||
|
|
||||||
|
protected readonly loaded = signal(false);
|
||||||
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly successMessage = signal<string | null>(null);
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
protected title = '';
|
||||||
|
protected description = '';
|
||||||
|
protected category = '';
|
||||||
|
|
||||||
|
// Read-only metadata
|
||||||
|
protected progressEventCount = 0;
|
||||||
|
protected benchmarkCount = 0;
|
||||||
|
|
||||||
|
// Snapshot
|
||||||
|
private savedTitle = '';
|
||||||
|
private savedDescription = '';
|
||||||
|
private savedCategory = '';
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns true if form values differ from the saved snapshot.
|
||||||
|
// *****************************************************************
|
||||||
|
hasChanges(): boolean {
|
||||||
|
return this.title !== this.savedTitle
|
||||||
|
|| this.description !== this.savedDescription
|
||||||
|
|| this.category !== this.savedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Saves changes to the goal via the API.
|
||||||
|
// *****************************************************************
|
||||||
|
async onSave() {
|
||||||
|
this.saving.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.successMessage.set(null);
|
||||||
|
|
||||||
|
const result = await this.studentService.updateGoal(this.studentId, this.goalId, {
|
||||||
|
title: this.title,
|
||||||
|
description: this.description,
|
||||||
|
category: this.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.saving.set(false);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.savedTitle = this.title;
|
||||||
|
this.savedDescription = this.description;
|
||||||
|
this.savedCategory = this.category;
|
||||||
|
this.successMessage.set('Changes saved.');
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Reverts form fields to the last-saved snapshot.
|
||||||
|
// *****************************************************************
|
||||||
|
onCancel() {
|
||||||
|
this.title = this.savedTitle;
|
||||||
|
this.description = this.savedDescription;
|
||||||
|
this.category = this.savedCategory;
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.successMessage.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBack() {
|
||||||
|
this.router.navigate(['/students', this.studentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgressEvents() {
|
||||||
|
this.router.navigate(['/students', this.studentId, 'goals', this.goalId, 'progress']);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBenchmarks() {
|
||||||
|
this.router.navigate(['/students', this.studentId, 'benchmarks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.paramSub.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Loads the goal by finding it in the student's goal list.
|
||||||
|
// *****************************************************************
|
||||||
|
private loadGoal() {
|
||||||
|
this.loaded.set(false);
|
||||||
|
this.studentService.getGoalsForStudent(this.studentId).then(result => {
|
||||||
|
if (!result.success || !result.payload) {
|
||||||
|
this.errorMessage.set(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goal = result.payload.goals.find(g => g.goalId === this.goalId);
|
||||||
|
if (!goal) {
|
||||||
|
this.errorMessage.set('Goal not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.title = goal.title;
|
||||||
|
this.description = goal.description;
|
||||||
|
this.category = goal.category;
|
||||||
|
this.progressEventCount = goal.progressEventCount;
|
||||||
|
this.benchmarkCount = goal.benchmarkCount;
|
||||||
|
|
||||||
|
this.savedTitle = goal.title;
|
||||||
|
this.savedDescription = goal.description;
|
||||||
|
this.savedCategory = goal.category;
|
||||||
|
this.loaded.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="card clickable" (click)="onCardClick()">
|
<div class="card" (click)="onCardClick()">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="category-badge">{{ goal().category }}</span>
|
<span class="category-badge">{{ goal().category }}</span>
|
||||||
<span class="event-count">{{ goal().progressEventCount }} events</span>
|
<span class="event-count">{{ goal().progressEventCount }} events</span>
|
||||||
@@ -6,4 +6,9 @@
|
|||||||
|
|
||||||
<h3 class="title">{{ goal().title }}</h3>
|
<h3 class="title">{{ goal().title }}</h3>
|
||||||
<p class="description">{{ goal().description }}</p>
|
<p class="description">{{ goal().description }}</p>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="footer-link" (click)="$event.stopPropagation(); onBenchmarksClick()">Benchmarks</a>
|
||||||
|
<a class="footer-link" (click)="$event.stopPropagation(); onProgressEventsClick()">Progress Events</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,16 +11,29 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
height: 130px;
|
cursor: pointer;
|
||||||
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.clickable {
|
.card-footer {
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 0 -1.5rem -1rem;
|
||||||
|
padding: 0.5rem 1.5rem 0.5rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.clickable:hover {
|
.footer-link {
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
font-size: 0.875rem;
|
||||||
|
color: #4f46e5;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: #4338ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
|
|||||||
@@ -26,9 +26,25 @@ export class GoalCard {
|
|||||||
// ************************ Event Handlers *************************
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Navigates to the progress events page for this goal.
|
// Navigates to the goal detail page.
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
onCardClick() {
|
onCardClick() {
|
||||||
|
const studentId = this.route.snapshot.paramMap.get('studentId')!;
|
||||||
|
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Navigates to the benchmarks page for this goal.
|
||||||
|
// *****************************************************************
|
||||||
|
onBenchmarksClick() {
|
||||||
|
const studentId = this.route.snapshot.paramMap.get('studentId')!;
|
||||||
|
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'benchmarks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Navigates to the progress events page for this goal.
|
||||||
|
// *****************************************************************
|
||||||
|
onProgressEventsClick() {
|
||||||
const studentId = this.route.snapshot.paramMap.get('studentId')!;
|
const studentId = this.route.snapshot.paramMap.get('studentId')!;
|
||||||
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']);
|
this.router.navigate(['/students', studentId, 'goals', this.goal().goalId, 'progress']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="toolbar-btn back-btn" (click)="onBack()">← Students</button>
|
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Student</button>
|
||||||
|
<span class="toolbar-title">Goals</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="generate-report"> ⭐ Generate progress report</button>
|
|
||||||
<button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button>
|
<button class="toolbar-btn" (click)="onAddGoal()">+ Add a Goal</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- <img class="hero-image" src="/hurdlescropped.png" alt="Hurdles" /> -->
|
||||||
|
|
||||||
|
|
||||||
@if (studentIdentifier()) {
|
@if (studentIdentifier()) {
|
||||||
<h2 class="section-header">Goals for {{ studentIdentifier() }}</h2>
|
<h2 class="section-header">Student: {{ studentIdentifier() }}</h2>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showAddModal()) {
|
@if (showAddModal()) {
|
||||||
@@ -27,3 +30,8 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<footer class="goal-footer">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="toolbar-btn"> ⭐ Generate progress report</button>
|
||||||
|
</footer>
|
||||||
@@ -4,27 +4,11 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-report {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #4000ee;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.generate-report:hover {
|
|
||||||
background-color: #4f46e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
@@ -55,6 +39,15 @@
|
|||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -86,3 +79,13 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.goal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
height: 48px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal, OnDestroy } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { StudentGoalItem } from '../../../shared/classes/student-goal';
|
import { StudentGoalItem } from '../../../shared/classes/student-goal';
|
||||||
import { StudentService } from '../../../shared/services/student.service';
|
import { StudentService } from '../../../shared/services/student.service';
|
||||||
import { GoalCard } from '../goal-card/goal-card';
|
import { GoalCard } from '../goal-card/goal-card';
|
||||||
@@ -11,13 +12,15 @@ import { AddGoalModal } from '../add-goal-modal/add-goal-modal';
|
|||||||
templateUrl: './goal-list.html',
|
templateUrl: './goal-list.html',
|
||||||
styleUrl: './goal-list.scss',
|
styleUrl: './goal-list.scss',
|
||||||
})
|
})
|
||||||
export class GoalList {
|
export class GoalList implements OnDestroy {
|
||||||
|
|
||||||
// ************************** Constructor **************************
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.studentId = this.route.snapshot.paramMap.get('studentId')!;
|
this.paramSub = this.route.paramMap.subscribe(params => {
|
||||||
|
this.studentId = params.get('studentId')!;
|
||||||
this.loadGoals();
|
this.loadGoals();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ************************** Declarations *************************
|
// ************************** Declarations *************************
|
||||||
@@ -25,8 +28,9 @@ export class GoalList {
|
|||||||
private readonly studentService = inject(StudentService);
|
private readonly studentService = inject(StudentService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly paramSub: Subscription;
|
||||||
|
|
||||||
protected readonly studentId: string;
|
protected studentId!: string;
|
||||||
protected readonly studentIdentifier = signal<string | null>(null);
|
protected readonly studentIdentifier = signal<string | null>(null);
|
||||||
protected readonly goals = signal<StudentGoalItem[]>([]);
|
protected readonly goals = signal<StudentGoalItem[]>([]);
|
||||||
protected readonly showAddModal = signal(false);
|
protected readonly showAddModal = signal(false);
|
||||||
@@ -52,7 +56,11 @@ export class GoalList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onBack() {
|
onBack() {
|
||||||
this.router.navigate(['/students']);
|
this.router.navigate(['/students', this.studentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.paramSub.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ********************** Support Procedures ***********************
|
// ********************** Support Procedures ***********************
|
||||||
|
|||||||
+9
-4
@@ -1,13 +1,18 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="toolbar-btn back-btn" (click)="onBack()">← Goals</button>
|
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Goal</button>
|
||||||
|
<span class="toolbar-title">Progress Events</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="toolbar-btn" (click)="onAddProgressEvent()">+ Add Progress Event</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- <img class="hero-image" src="/slalomcropped.png" alt="Slalom" /> -->
|
||||||
|
|
||||||
@if (studentIdentifier() && goalTitle()) {
|
@if (studentIdentifier() && goalTitle()) {
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<h2 class="section-header">
|
<h2 class="section-header">
|
||||||
{{ events().length }} Progress Events for {{ studentIdentifier() }} for goal: {{ goalTitle() }}
|
Student: {{ studentIdentifier() }} Goal: {{ goalTitle() }}
|
||||||
@if (isFiltered()) {
|
@if (isFiltered()) {
|
||||||
<span class="filter-count">(showing {{ filteredEvents().length }})</span>
|
<span class="filter-count">(showing {{ filteredEvents().length }} of {{ events().length }})</span>
|
||||||
}
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
@@ -25,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (filteredEvents().length === 0 && !errorMessage()) {
|
@if (filteredEvents().length === 0 && !errorMessage()) {
|
||||||
<p class="empty-state">No progress events recorded yet.</p>
|
<p class="empty-state">No progress events recorded yet. Click <strong>+ Add Progress Event</strong> to get started.</p>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="event-list">
|
<div class="event-list">
|
||||||
@for (evt of filteredEvents(); track evt.progressEventId) {
|
@for (evt of filteredEvents(); track evt.progressEventId) {
|
||||||
|
|||||||
@@ -4,9 +4,19 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
max-width: 50%;
|
||||||
|
max-height: 100px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
@@ -17,6 +27,19 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class ProgressList implements OnDestroy {
|
|||||||
protected readonly errorMessage = signal<string | null>(null);
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
protected readonly rawSearchText = signal('');
|
protected readonly rawSearchText = signal('');
|
||||||
protected readonly searchTerm = signal('');
|
protected readonly searchTerm = signal('');
|
||||||
|
protected readonly showAddModal = signal(false);
|
||||||
|
|
||||||
// ************************** Properties ***************************
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
@@ -65,11 +66,16 @@ export class ProgressList implements OnDestroy {
|
|||||||
|
|
||||||
// ************************ Event Handlers *************************
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
onAddProgressEvent() {
|
||||||
|
this.showAddModal.set(true);
|
||||||
|
// TODO: Wire up add-progress-event modal component
|
||||||
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Navigates back to the goals list for this student.
|
// Navigates back to the parent goal detail.
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
onBack() {
|
onBack() {
|
||||||
this.router.navigate(['/students', this.studentId, 'goals']);
|
this.router.navigate(['/students', this.studentId, 'goals', this.goalId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
@for (node of nodes(); track node.label) {
|
||||||
|
<div class="node-row" [style.padding-left]="indent()">
|
||||||
|
@if (hasToggle(node)) {
|
||||||
|
<span class="toggle-indicator" (click)="onToggle(node, $event)">{{ node.expanded ? '−' : '+' }}</span>
|
||||||
|
}
|
||||||
|
@if (node.routerLink) {
|
||||||
|
<a class="node-label" [routerLink]="node.routerLink" routerLinkActive="active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }">{{ node.label }}</a>
|
||||||
|
} @else if (hasToggle(node)) {
|
||||||
|
<span class="node-label clickable" (click)="onToggle(node, $event)">{{ node.label }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="node-label">{{ node.label }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (node.expanded && node.children) {
|
||||||
|
<app-sidebar-tree-node [nodes]="node.children" [depth]="depth() + 1" />
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row:has(.node-label.active) {
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label.active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { SidebarNode } from '../../../shared/classes/sidebar-node';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sidebar-tree-node',
|
||||||
|
imports: [RouterLink, RouterLinkActive, SidebarTreeNode],
|
||||||
|
templateUrl: './sidebar-tree-node.html',
|
||||||
|
styleUrl: './sidebar-tree-node.scss',
|
||||||
|
})
|
||||||
|
export class SidebarTreeNode {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
readonly nodes = input.required<SidebarNode[]>();
|
||||||
|
readonly depth = input<number>(0);
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Computed indentation in rem based on depth.
|
||||||
|
// *****************************************************************
|
||||||
|
indent(): string {
|
||||||
|
return (1 + this.depth() * 1.5) + 'rem';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns true if a node should show the +/- toggle.
|
||||||
|
// A node is expandable if it has static children, or has a
|
||||||
|
// loadChildren function with a non-zero childCount.
|
||||||
|
// *****************************************************************
|
||||||
|
hasToggle(node: SidebarNode): boolean {
|
||||||
|
if (node.children && node.children.length > 0) return true;
|
||||||
|
if (node.loadChildren && node.childCount !== 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Toggles a node's expanded state. On first expand of a lazy node,
|
||||||
|
// calls loadChildren and caches the result in node.children.
|
||||||
|
// *****************************************************************
|
||||||
|
async onToggle(node: SidebarNode, event: Event) {
|
||||||
|
if (!this.hasToggle(node)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (node.expanded) {
|
||||||
|
node.expanded = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.loadChildren && !node.children) {
|
||||||
|
node.children = await node.loadChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
node.expanded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="toolbar">
|
||||||
|
<button class="toolbar-btn back-btn" (click)="onBack()">↑ Students</button>
|
||||||
|
<span class="toolbar-title">Student Detail</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<p class="error">{{ errorMessage() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@if (loaded()) {
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="identifier">Name</label>
|
||||||
|
<input id="identifier" class="field-input" type="text" [(ngModel)]="identifier" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="expectedGrad">Expected Graduation</label>
|
||||||
|
<input id="expectedGrad" class="field-input" type="date" [(ngModel)]="expectedGradDate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="toolbar-btn" (click)="onCancel()" [disabled]="!hasChanges()">Cancel</button>
|
||||||
|
<button class="toolbar-btn save-btn" (click)="onSave()" [disabled]="!hasChanges() || saving()">
|
||||||
|
{{ saving() ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="detail-link" (click)="onGoals()">Goals</a>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
@if (successMessage()) {
|
||||||
|
<span class="success-label" [class.fade-out]="fading()">{{ successMessage() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 40px;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #4f46e5;
|
||||||
|
border: 1px solid #4f46e5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #dc2626;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1rem -1.5rem -1rem;
|
||||||
|
padding: 0.5rem 1.5rem 0.5rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4f46e5;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link:hover {
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #16a34a;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-label.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions .toolbar-btn {
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { StudentCardFull } from './student-card-full';
|
||||||
|
|
||||||
|
describe('StudentCardFull', () => {
|
||||||
|
let component: StudentCardFull;
|
||||||
|
let fixture: ComponentFixture<StudentCardFull>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [StudentCardFull]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(StudentCardFull);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
import { Component, inject, signal, OnDestroy } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { StudentService } from '../../../shared/services/student.service';
|
||||||
|
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-student-card-full',
|
||||||
|
imports: [FormsModule],
|
||||||
|
templateUrl: './student-card-full.html',
|
||||||
|
styleUrl: './student-card-full.scss',
|
||||||
|
})
|
||||||
|
export class StudentCardFull implements OnDestroy {
|
||||||
|
|
||||||
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.paramSub = this.route.paramMap.subscribe(params => {
|
||||||
|
this.studentId = params.get('studentId')!;
|
||||||
|
this.loadStudent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
|
private readonly studentService = inject(StudentService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly paramSub: Subscription;
|
||||||
|
|
||||||
|
private studentId!: string;
|
||||||
|
|
||||||
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly successMessage = signal<string | null>(null);
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly loaded = signal(false);
|
||||||
|
protected readonly fading = signal(false);
|
||||||
|
private successTimer: any = null;
|
||||||
|
|
||||||
|
// Form fields — always editable
|
||||||
|
protected identifier = '';
|
||||||
|
protected expectedGradDate = '';
|
||||||
|
|
||||||
|
// Snapshot of last-saved values for cancel
|
||||||
|
private savedIdentifier = '';
|
||||||
|
private savedExpectedGradDate = '';
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns true if form values differ from the saved snapshot.
|
||||||
|
// *****************************************************************
|
||||||
|
hasChanges(): boolean {
|
||||||
|
return this.identifier !== this.savedIdentifier
|
||||||
|
|| this.expectedGradDate !== this.savedExpectedGradDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Saves changes to the student via the API.
|
||||||
|
// *****************************************************************
|
||||||
|
async onSave() {
|
||||||
|
this.saving.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.successMessage.set(null);
|
||||||
|
|
||||||
|
const result = await this.studentService.updateStudent(this.studentId, {
|
||||||
|
identifier: this.identifier,
|
||||||
|
expectedGrad: this.expectedGradDate || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.saving.set(false);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.savedIdentifier = this.identifier;
|
||||||
|
this.savedExpectedGradDate = this.expectedGradDate;
|
||||||
|
this.showSuccessTemporarily('Changes saved.');
|
||||||
|
this.studentService.notifyDataChanged();
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Reverts form fields to the last-saved snapshot.
|
||||||
|
// *****************************************************************
|
||||||
|
onCancel() {
|
||||||
|
this.identifier = this.savedIdentifier;
|
||||||
|
this.expectedGradDate = this.savedExpectedGradDate;
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.successMessage.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBack() {
|
||||||
|
this.router.navigate(['/students']);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGoals() {
|
||||||
|
this.router.navigate(['/students', this.studentId, 'goals']);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.paramSub.unsubscribe();
|
||||||
|
if (this.successTimer) clearTimeout(this.successTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Shows a success message for 4 seconds, then fades it out over 1s.
|
||||||
|
// *****************************************************************
|
||||||
|
private showSuccessTemporarily(message: string) {
|
||||||
|
if (this.successTimer) clearTimeout(this.successTimer);
|
||||||
|
this.fading.set(false);
|
||||||
|
this.successMessage.set(message);
|
||||||
|
|
||||||
|
this.successTimer = setTimeout(() => {
|
||||||
|
this.fading.set(true);
|
||||||
|
this.successTimer = setTimeout(() => {
|
||||||
|
this.successMessage.set(null);
|
||||||
|
this.fading.set(false);
|
||||||
|
}, 1000);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Loads the student by ID and populates form fields.
|
||||||
|
// *****************************************************************
|
||||||
|
private loadStudent() {
|
||||||
|
if (!this.loaded()) {
|
||||||
|
this.loaded.set(false);
|
||||||
|
}
|
||||||
|
this.studentService.getStudentById(this.studentId).then(result => {
|
||||||
|
if (result.success && result.payload) {
|
||||||
|
const s = result.payload;
|
||||||
|
this.identifier = s.identifier;
|
||||||
|
this.expectedGradDate = this.toDateInput(s.expectedGradDate);
|
||||||
|
|
||||||
|
this.savedIdentifier = this.identifier;
|
||||||
|
this.savedExpectedGradDate = this.expectedGradDate;
|
||||||
|
this.loaded.set(true);
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Converts a Date to a YYYY-MM-DD string for date input binding.
|
||||||
|
// *****************************************************************
|
||||||
|
private toDateInput(date: Date | null): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -16,10 +16,11 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin-left: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
@@ -40,8 +41,8 @@
|
|||||||
.card-grid {
|
.card-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -41,7 +41,7 @@ export class StudentCardList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onStudentCreated(student: StudentCardDto) {
|
onStudentCreated(student: StudentCardDto) {
|
||||||
this.students.update(list => [...list, student]);
|
this.students.update(list => this.sortByIdentifier([...list, student]));
|
||||||
this.showAddModal.set(false);
|
this.showAddModal.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,19 +51,26 @@ export class StudentCardList {
|
|||||||
|
|
||||||
// ********************** Support Procedures ***********************
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Sorts an array of students alphabetically by identifier.
|
||||||
|
// *****************************************************************
|
||||||
|
private sortByIdentifier(students: StudentCardDto[]): StudentCardDto[] {
|
||||||
|
return students.sort((a, b) =>
|
||||||
|
a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Loads students from the service and populates the students signal.
|
// Loads students from the service and populates the students signal.
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
private loadStudents() {
|
private loadStudents() {
|
||||||
this.studentService.getMyStudents().then(data => {
|
this.studentService.getMyStudents().then(data => {
|
||||||
|
|
||||||
if(!data.success)
|
if (!data.success) {
|
||||||
{
|
|
||||||
this.errorMessage.set(data.message);
|
this.errorMessage.set(data.message);
|
||||||
}
|
}
|
||||||
else
|
else {
|
||||||
{
|
this.students.set(this.sortByIdentifier(data.payload || []))
|
||||||
this.students.set(data.payload || [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="card" [routerLink]="['/students', student().studentId, 'goals']">
|
<div class="card" [routerLink]="['/students', student().studentId]">
|
||||||
<h2 class="identifier">🎓 {{ student().identifier }}</h2>
|
<h2 class="identifier">🎓 {{ student().identifier }}</h2>
|
||||||
|
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { Home } from './pages/home/home';
|
import { Home } from './pages/home/home';
|
||||||
import { StudentCardList } from './components/student-card-list/student-card-list';
|
import { StudentCardList } from './components/student-card-list/student-card-list';
|
||||||
|
import { StudentCardFull } from './components/student-card-full/student-card-full';
|
||||||
import { GoalList } from './components/goal-list/goal-list';
|
import { GoalList } from './components/goal-list/goal-list';
|
||||||
|
import { GoalCardFull } from './components/goal-card-full/goal-card-full';
|
||||||
import { ProgressList } from './components/progress-list/progress-list';
|
import { ProgressList } from './components/progress-list/progress-list';
|
||||||
|
import { BenchmarkList } from './components/benchmark-list/benchmark-list';
|
||||||
|
import { BenchmarkCardFull } from './components/benchmark-card-full/benchmark-card-full';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
@@ -11,8 +15,14 @@ export default [
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: 'students', pathMatch: 'full' },
|
{ path: '', redirectTo: 'students', pathMatch: 'full' },
|
||||||
{ path: 'students', component: StudentCardList },
|
{ path: 'students', component: StudentCardList },
|
||||||
|
{ path: 'students/:studentId', component: StudentCardFull },
|
||||||
{ path: 'students/:studentId/goals', component: GoalList },
|
{ path: 'students/:studentId/goals', component: GoalList },
|
||||||
|
{ path: 'students/:studentId/goals/:goalId', component: GoalCardFull },
|
||||||
{ path: 'students/:studentId/goals/:goalId/progress', component: ProgressList },
|
{ path: 'students/:studentId/goals/:goalId/progress', component: ProgressList },
|
||||||
|
{ path: 'students/:studentId/goals/:goalId/benchmarks', component: BenchmarkList },
|
||||||
|
{ path: 'students/:studentId/goals/:goalId/benchmarks/new', component: BenchmarkCardFull },
|
||||||
|
{ path: 'students/:studentId/goals/:goalId/benchmarks/:benchmarkId', component: BenchmarkCardFull },
|
||||||
|
{ path: 'students/:studentId/benchmarks', component: BenchmarkList },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] satisfies Routes;
|
] satisfies Routes;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="body">
|
<div class="body">
|
||||||
<nav class="sidebar" [class.expanded]="sidebarExpanded()">
|
<nav class="sidebar" [class.expanded]="sidebarExpanded()">
|
||||||
<a class="nav-item" routerLink="/students">Home</a>
|
<a class="nav-item" routerLink="/students">Home</a>
|
||||||
<a class="nav-item sub" routerLink="/students" routerLinkActive="active">My Students</a>
|
<app-sidebar-tree-node [nodes]="sidebarTree()" [depth]="0" />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.expanded {
|
.sidebar.expanded {
|
||||||
width: 220px;
|
width: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -95,11 +96,6 @@
|
|||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.sub {
|
|
||||||
padding-left: 2rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4f46e5;
|
color: #4f46e5;
|
||||||
|
|||||||
@@ -1,21 +1,52 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, effect, inject, OnDestroy, signal } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
import { Auth } from '../../../shared/services/auth';
|
import { Auth } from '../../../shared/services/auth';
|
||||||
|
import { StudentService } from '../../../shared/services/student.service';
|
||||||
|
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
|
||||||
|
import { SidebarNode } from '../../../shared/classes/sidebar-node';
|
||||||
|
import { SidebarTreeNode } from '../../components/sidebar-tree-node/sidebar-tree-node';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
imports: [RouterOutlet, RouterLink, SidebarTreeNode],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
styleUrl: './home.scss',
|
styleUrl: './home.scss',
|
||||||
})
|
})
|
||||||
export class Home {
|
export class Home implements OnDestroy {
|
||||||
|
|
||||||
// ************************** Constructor **************************
|
// ************************** Constructor **************************
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadStudents();
|
||||||
|
|
||||||
|
// Reload the sidebar tree whenever data changes elsewhere.
|
||||||
|
let initialized = false;
|
||||||
|
effect(() => {
|
||||||
|
this.studentService.dataVersion();
|
||||||
|
if (initialized) {
|
||||||
|
this.loadStudents();
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-expand sidebar nodes to match the current route.
|
||||||
|
this.routeSub = this.router.events.pipe(
|
||||||
|
filter(e => e instanceof NavigationEnd)
|
||||||
|
).subscribe(() => {
|
||||||
|
this.expandToRoute(this.router.url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ************************** Declarations *************************
|
// ************************** Declarations *************************
|
||||||
|
|
||||||
private readonly auth = inject(Auth);
|
private readonly auth = inject(Auth);
|
||||||
protected readonly sidebarExpanded = signal(false);
|
private readonly router = inject(Router);
|
||||||
|
private readonly studentService = inject(StudentService);
|
||||||
|
private readonly routeSub: Subscription;
|
||||||
|
protected readonly sidebarExpanded = signal(true);
|
||||||
|
protected readonly sidebarTree = signal<SidebarNode[]>([]);
|
||||||
|
|
||||||
// ************************** Properties ***************************
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
@@ -35,5 +66,132 @@ export class Home {
|
|||||||
this.auth.forceLogout();
|
this.auth.forceLogout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.routeSub.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
// ********************** Support Procedures ***********************
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Loads student list, sorts by identifier, and builds the sidebar
|
||||||
|
// tree with lazy-loading callbacks for goals and benchmarks.
|
||||||
|
// *****************************************************************
|
||||||
|
private loadStudents() {
|
||||||
|
this.studentService.getMyStudents().then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const sorted = (data.payload || []).sort((a, b) =>
|
||||||
|
a.identifier.localeCompare(b.identifier, undefined, { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
this.sidebarTree.set(this.buildTree(sorted));
|
||||||
|
this.expandToRoute(this.router.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Builds the sidebar node tree from a list of students.
|
||||||
|
// *****************************************************************
|
||||||
|
private buildTree(students: StudentCardDto[]): SidebarNode[] {
|
||||||
|
return [{
|
||||||
|
label: 'My Students',
|
||||||
|
routerLink: ['/students'],
|
||||||
|
expanded: true,
|
||||||
|
childCount: students.length,
|
||||||
|
children: students.map(s => ({
|
||||||
|
label: s.identifier,
|
||||||
|
routerLink: ['/students', s.studentId],
|
||||||
|
childCount: s.goalCount > 0 ? 1 : 0,
|
||||||
|
children: s.goalCount > 0 ? [{
|
||||||
|
label: 'Goals',
|
||||||
|
routerLink: ['/students', s.studentId, 'goals'],
|
||||||
|
childCount: s.goalCount,
|
||||||
|
loadChildren: () => this.loadGoalNodes(s.studentId),
|
||||||
|
}] : undefined,
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Lazy-loads individual goal nodes for a student. Called when
|
||||||
|
// the "Goals" node is expanded for the first time.
|
||||||
|
// *****************************************************************
|
||||||
|
private async loadGoalNodes(studentId: string): Promise<SidebarNode[]> {
|
||||||
|
const result = await this.studentService.getGoalsForStudent(studentId);
|
||||||
|
if (!result.success || !result.payload) return [];
|
||||||
|
|
||||||
|
return result.payload.goals.map(goal => ({
|
||||||
|
label: goal.title,
|
||||||
|
routerLink: ['/students', studentId, 'goals', goal.goalId],
|
||||||
|
childCount: 2,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Progress Events',
|
||||||
|
routerLink: ['/students', studentId, 'goals', goal.goalId, 'progress'],
|
||||||
|
childCount: goal.progressEventCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Benchmarks',
|
||||||
|
routerLink: ['/students', studentId, 'goals', goal.goalId, 'benchmarks'],
|
||||||
|
childCount: goal.benchmarkCount,
|
||||||
|
loadChildren: goal.benchmarkCount > 0
|
||||||
|
? () => this.loadBenchmarkNodes(studentId, goal.goalId)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Lazy-loads benchmark leaf nodes for a goal. Called when a
|
||||||
|
// "Benchmarks" node is expanded for the first time.
|
||||||
|
// *****************************************************************
|
||||||
|
private async loadBenchmarkNodes(studentId: string, goalId: string): Promise<SidebarNode[]> {
|
||||||
|
const result = await this.studentService.getBenchmarksForStudent(studentId);
|
||||||
|
if (!result.success || !result.payload) return [];
|
||||||
|
|
||||||
|
return result.payload.benchmarks
|
||||||
|
.filter(b => b.goalId === goalId)
|
||||||
|
.map(b => ({
|
||||||
|
label: b.benchmark,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Walks the sidebar tree and expands any node whose routerLink is
|
||||||
|
// a prefix of the current URL. Triggers lazy loading if needed.
|
||||||
|
// Returns true if the current URL matches or is a descendant of
|
||||||
|
// any node in the given list.
|
||||||
|
// *****************************************************************
|
||||||
|
private async expandToRoute(url: string, nodes?: SidebarNode[]): Promise<boolean> {
|
||||||
|
const tree = nodes || this.sidebarTree();
|
||||||
|
let matched = false;
|
||||||
|
|
||||||
|
for (const node of tree) {
|
||||||
|
const nodePath = node.routerLink ? node.routerLink.join('/') : '';
|
||||||
|
|
||||||
|
// Check if this node is the target or an ancestor of the target.
|
||||||
|
const isMatch = nodePath !== '' && url === nodePath;
|
||||||
|
const isAncestor = nodePath !== '' && url.startsWith(nodePath + '/');
|
||||||
|
|
||||||
|
if (isMatch || isAncestor) {
|
||||||
|
matched = true;
|
||||||
|
|
||||||
|
if (isAncestor) {
|
||||||
|
// Expand this node to reveal children.
|
||||||
|
if (node.loadChildren && !node.children) {
|
||||||
|
node.children = await node.loadChildren();
|
||||||
|
}
|
||||||
|
node.expanded = true;
|
||||||
|
|
||||||
|
// Continue down the tree.
|
||||||
|
if (node.children) {
|
||||||
|
await this.expandToRoute(url, node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface StudentBenchmarkSummary {
|
||||||
|
studentIdentifier: string;
|
||||||
|
benchmarks: BenchmarkDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BenchmarkDto {
|
||||||
|
benchmarkId: string;
|
||||||
|
goalId: string;
|
||||||
|
goalTitle: string;
|
||||||
|
benchmark: string;
|
||||||
|
createdByName: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface SidebarNode {
|
||||||
|
label: string;
|
||||||
|
routerLink?: string[];
|
||||||
|
children?: SidebarNode[];
|
||||||
|
expanded?: boolean;
|
||||||
|
loadChildren?: () => Promise<SidebarNode[]>;
|
||||||
|
childCount?: number;
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@ export interface StudentGoalItem {
|
|||||||
description: string; // goal.description — text
|
description: string; // goal.description — text
|
||||||
category: string; // goal.category — varchar(100)
|
category: string; // goal.category — varchar(100)
|
||||||
progressEventCount: number; // count of progress_event rows for this goal
|
progressEventCount: number; // count of progress_event rows for this goal
|
||||||
|
benchmarkCount: number; // count of benchmark rows for this goal
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiResult } from '../classes/api-result';
|
|||||||
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
||||||
import { CreateGoalDto } from '../classes/create-goal.dto';
|
import { CreateGoalDto } from '../classes/create-goal.dto';
|
||||||
import { ProgressEventDto } from '../classes/progress-event.dto';
|
import { ProgressEventDto } from '../classes/progress-event.dto';
|
||||||
|
import { StudentBenchmarkSummary, BenchmarkDto } from '../classes/benchmark.dto';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -18,41 +19,41 @@ export class DummyStudentService {
|
|||||||
'1': {
|
'1': {
|
||||||
studentIdentifier: 'J.B',
|
studentIdentifier: 'J.B',
|
||||||
goals: [
|
goals: [
|
||||||
{ goalId: 'g1', goalParentId: null, title: 'Improve reading comprehension', description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', progressEventCount: 5 },
|
{ goalId: 'g1', goalParentId: null, title: 'Improve reading comprehension', description: 'Work on main-idea identification and inference skills across fiction and nonfiction texts.', category: 'Academics', progressEventCount: 5, benchmarkCount: 2 },
|
||||||
{ goalId: 'g2', goalParentId: null, title: 'Complete algebra module', description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', progressEventCount: 2 },
|
{ goalId: 'g2', goalParentId: null, title: 'Complete algebra module', description: 'Finish all units in the algebra course including linear equations and graphing.', category: 'Academics', progressEventCount: 2, benchmarkCount: 0 },
|
||||||
{ goalId: 'g3', goalParentId: null, title: 'Weekly journal entries', description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', progressEventCount: 8 },
|
{ goalId: 'g3', goalParentId: null, title: 'Weekly journal entries', description: 'Write a reflective journal entry each week to build writing fluency.', category: 'Communication', progressEventCount: 8, benchmarkCount: 1 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'2': {
|
'2': {
|
||||||
studentIdentifier: 'M.K',
|
studentIdentifier: 'M.K',
|
||||||
goals: [
|
goals: [
|
||||||
{ goalId: 'g4', goalParentId: null, title: 'Pass certification exam', description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', progressEventCount: 3 },
|
{ goalId: 'g4', goalParentId: null, title: 'Pass certification exam', description: 'Prepare for and pass the industry certification exam by end of quarter.', category: 'Career Readiness', progressEventCount: 3, benchmarkCount: 0 },
|
||||||
{ goalId: 'g5', goalParentId: null, title: 'Attendance above 90%', description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', progressEventCount: 0 },
|
{ goalId: 'g5', goalParentId: null, title: 'Attendance above 90%', description: 'Maintain consistent attendance throughout the term.', category: 'Behavior', progressEventCount: 0, benchmarkCount: 0 },
|
||||||
{ goalId: 'g6', goalParentId: null, title: 'Complete internship hours', description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', progressEventCount: 12 },
|
{ goalId: 'g6', goalParentId: null, title: 'Complete internship hours', description: 'Log the required 40 hours at the assigned internship site.', category: 'Career Readiness', progressEventCount: 12, benchmarkCount: 0 },
|
||||||
{ goalId: 'g7', goalParentId: null, title: 'Portfolio project', description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', progressEventCount: 1 },
|
{ goalId: 'g7', goalParentId: null, title: 'Portfolio project', description: 'Build a personal portfolio showcasing completed coursework and projects.', category: 'Career Readiness', progressEventCount: 1, benchmarkCount: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'3': {
|
'3': {
|
||||||
studentIdentifier: 'A.R',
|
studentIdentifier: 'A.R',
|
||||||
goals: [
|
goals: [
|
||||||
{ goalId: 'g8', goalParentId: null, title: 'GED preparation', description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', progressEventCount: 6 },
|
{ goalId: 'g8', goalParentId: null, title: 'GED preparation', description: 'Complete practice tests and study modules for GED math and reading sections.', category: 'Academics', progressEventCount: 6, benchmarkCount: 0 },
|
||||||
{ goalId: 'g9', goalParentId: null, title: 'Resume workshop', description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', progressEventCount: 0 },
|
{ goalId: 'g9', goalParentId: null, title: 'Resume workshop', description: 'Attend the resume writing workshop and produce a final draft.', category: 'Career Readiness', progressEventCount: 0, benchmarkCount: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'4': {
|
'4': {
|
||||||
studentIdentifier: 'T.W',
|
studentIdentifier: 'T.W',
|
||||||
goals: [
|
goals: [
|
||||||
{ goalId: 'g10', goalParentId: null, title: 'Public speaking practice', description: 'Present in front of the class at least once per month.', category: 'Communication', progressEventCount: 4 },
|
{ goalId: 'g10', goalParentId: null, title: 'Public speaking practice', description: 'Present in front of the class at least once per month.', category: 'Communication', progressEventCount: 4, benchmarkCount: 0 },
|
||||||
{ goalId: 'g11', goalParentId: null, title: 'Math placement improvement', description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', progressEventCount: 7 },
|
{ goalId: 'g11', goalParentId: null, title: 'Math placement improvement', description: 'Move up one placement level in math by the end of the semester.', category: 'Academics', progressEventCount: 7, benchmarkCount: 0 },
|
||||||
{ goalId: 'g12', goalParentId: null, title: 'Conflict resolution strategies', description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', progressEventCount: 2 },
|
{ goalId: 'g12', goalParentId: null, title: 'Conflict resolution strategies', description: 'Learn and apply at least three de-escalation techniques.', category: 'Behavior', progressEventCount: 2, benchmarkCount: 0 },
|
||||||
{ goalId: 'g13', goalParentId: null, title: 'Daily attendance streak', description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', progressEventCount: 0 },
|
{ goalId: 'g13', goalParentId: null, title: 'Daily attendance streak', description: 'Achieve a 30-day unbroken attendance streak.', category: 'Behavior', progressEventCount: 0, benchmarkCount: 0 },
|
||||||
{ goalId: 'g14', goalParentId: null, title: 'Job shadow experience', description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', progressEventCount: 1 },
|
{ goalId: 'g14', goalParentId: null, title: 'Job shadow experience', description: 'Complete a job shadow day in a field of interest.', category: 'Career Readiness', progressEventCount: 1, benchmarkCount: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'5': {
|
'5': {
|
||||||
studentIdentifier: 'L.C',
|
studentIdentifier: 'L.C',
|
||||||
goals: [
|
goals: [
|
||||||
{ goalId: 'g15', goalParentId: null, title: 'Improve typing speed', description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', progressEventCount: 3 },
|
{ goalId: 'g15', goalParentId: null, title: 'Improve typing speed', description: 'Reach 40 WPM with 95% accuracy on typing assessments.', category: 'Career Readiness', progressEventCount: 3, benchmarkCount: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -119,6 +120,7 @@ export class DummyStudentService {
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
category: data.category,
|
category: data.category,
|
||||||
progressEventCount: 0,
|
progressEventCount: 0,
|
||||||
|
benchmarkCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
student.goals.push(newGoal);
|
student.goals.push(newGoal);
|
||||||
@@ -161,6 +163,28 @@ export class DummyStudentService {
|
|||||||
return ApiResult.ok(events);
|
return ApiResult.ok(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns hardcoded benchmarks for a given student.
|
||||||
|
// TODO: Replace with actual API call
|
||||||
|
// *****************************************************************
|
||||||
|
async getBenchmarksForStudent(studentId: string): Promise<ApiResult<StudentBenchmarkSummary | null>> {
|
||||||
|
const studentGoals = this.data[studentId];
|
||||||
|
if (!studentGoals) {
|
||||||
|
return ApiResult.fail('Student not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmarks: BenchmarkDto[] = [
|
||||||
|
{ benchmarkId: 'bm1', goalId: 'g1', goalTitle: 'Improve reading comprehension', benchmark: 'Student will identify the main idea of a grade-level nonfiction passage with 80% accuracy.', createdByName: 'Jane Smith', createdAt: new Date('2026-02-15'), updatedAt: null },
|
||||||
|
{ benchmarkId: 'bm2', goalId: 'g1', goalTitle: 'Improve reading comprehension', benchmark: 'Student will make at least two supported inferences per reading session.', createdByName: 'Jane Smith', createdAt: new Date('2026-02-16'), updatedAt: new Date('2026-02-20') },
|
||||||
|
{ benchmarkId: 'bm3', goalId: 'g3', goalTitle: 'Weekly journal entries', benchmark: 'Student will complete a minimum of one paragraph (5 sentences) per journal entry.', createdByName: 'John Doe', createdAt: new Date('2026-02-18'), updatedAt: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
return ApiResult.ok({
|
||||||
|
studentIdentifier: studentGoals.studentIdentifier,
|
||||||
|
benchmarks: benchmarks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ************************ Event Handlers *************************
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable, signal } from '@angular/core';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ApiResult } from '../classes/api-result';
|
import { ApiResult } from '../classes/api-result';
|
||||||
@@ -10,6 +10,7 @@ import { CreateGoalDto } from '../classes/create-goal.dto';
|
|||||||
import { StudentCardDto } from '../classes/student-card.dto';
|
import { StudentCardDto } from '../classes/student-card.dto';
|
||||||
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
||||||
import { ProgressEventDto } from '../classes/progress-event.dto';
|
import { ProgressEventDto } from '../classes/progress-event.dto';
|
||||||
|
import { StudentBenchmarkSummary } from '../classes/benchmark.dto';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -23,10 +24,20 @@ export class StudentService {
|
|||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly base = environment.apiBaseUrl;
|
private readonly base = environment.apiBaseUrl;
|
||||||
|
|
||||||
|
// Incremented after any data mutation so subscribers can refresh.
|
||||||
|
readonly dataVersion = signal(0);
|
||||||
|
|
||||||
// ************************** Properties ***************************
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
// ************************ Public Methods *************************
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Increments the data version signal so subscribers can refresh.
|
||||||
|
// *****************************************************************
|
||||||
|
notifyDataChanged() {
|
||||||
|
this.dataVersion.update(v => v + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Returns student card summaries for the authenticated user.
|
// Returns student card summaries for the authenticated user.
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
@@ -123,4 +134,100 @@ export class StudentService {
|
|||||||
// ************************ Event Handlers *************************
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
// ********************** Support Procedures ***********************
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns a single student by ID.
|
||||||
|
// *****************************************************************
|
||||||
|
async getStudentById(studentId: string): Promise<ApiResult<StudentCardDto>> {
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.get<ResponseResult<StudentCardDto>>(`${this.base}/api/Student/${studentId}`)
|
||||||
|
);
|
||||||
|
return result.success && result.data
|
||||||
|
? ApiResult.ok(result.data)
|
||||||
|
: ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Updates a student and returns the refreshed student data.
|
||||||
|
// *****************************************************************
|
||||||
|
async updateStudent(studentId: string, data: { identifier?: string; programYear?: number | null; enrollmentDate?: string | null; expectedGrad?: string | null }): Promise<ApiResult<StudentCardDto>> {
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.put<ResponseResult<StudentCardDto>>(`${this.base}/api/Student/${studentId}`, data)
|
||||||
|
);
|
||||||
|
return result.success && result.data
|
||||||
|
? ApiResult.ok(result.data)
|
||||||
|
: ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns benchmarks for a given student.
|
||||||
|
// *****************************************************************
|
||||||
|
async getBenchmarksForStudent(studentId: string): Promise<ApiResult<StudentBenchmarkSummary | null>> {
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.get<ResponseResult<StudentBenchmarkSummary>>(`${this.base}/api/Student/${studentId}/benchmarks`)
|
||||||
|
);
|
||||||
|
return result.success && result.data
|
||||||
|
? ApiResult.ok(result.data)
|
||||||
|
: ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Creates a new benchmark for a student.
|
||||||
|
// *****************************************************************
|
||||||
|
async createBenchmark(studentId: string, data: { goalId: string; benchmark: string }): Promise<ApiResult<any>> {
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.post<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/benchmarks`, data)
|
||||||
|
);
|
||||||
|
return result.success
|
||||||
|
? ApiResult.ok(result.data)
|
||||||
|
: ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Updates a benchmark's text.
|
||||||
|
// *****************************************************************
|
||||||
|
async updateBenchmark(studentId: string, benchmarkId: string, benchmarkText: string): Promise<ApiResult<any>> {
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.put<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/benchmarks/${benchmarkId}`, { benchmark: benchmarkText })
|
||||||
|
);
|
||||||
|
return result.success
|
||||||
|
? ApiResult.ok(result.data)
|
||||||
|
: ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Updates a goal's title, description, and category.
|
||||||
|
// *****************************************************************
|
||||||
|
async updateGoal(studentId: string, goalId: string, data: { title?: string; description?: string; category?: string }): Promise<ApiResult<any>> {
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.put<ResponseResult<any>>(`${this.base}/api/Student/${studentId}/goals/${goalId}`, data)
|
||||||
|
);
|
||||||
|
return result.success
|
||||||
|
? ApiResult.ok(result.data)
|
||||||
|
: ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user