mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 09:57:37 +00:00
consolidated API endpoint for student data loading
This commit is contained in:
@@ -97,6 +97,52 @@ public class StudentController : BaseController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{idStudent:guid}/full")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<StudentFullProfileResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<StudentFullProfileResponse>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ResponseResult<StudentFullProfileResponse>>> GetFullProfile(Guid idStudent)
|
||||||
|
{
|
||||||
|
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role, "all");
|
||||||
|
|
||||||
|
if (!students.Select(s => s.StudentId).Contains(idStudent))
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<StudentFullProfileResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await _studentRepository.GetFullProfileAsync(idStudent);
|
||||||
|
if (profile is null)
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<StudentFullProfileResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with ownership info from the authorization query.
|
||||||
|
var match = students.Single(s => s.StudentId == idStudent);
|
||||||
|
profile.Student.OwnerName = match.OwnerName;
|
||||||
|
profile.Student.IsMine = match.IsMine;
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<StudentFullProfileResponse>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Student profile retrieved successfully.",
|
||||||
|
Data = profile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{idStudent:guid}/goals")]
|
[HttpGet("{idStudent:guid}/goals")]
|
||||||
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
|
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
|
||||||
[ProducesResponseType(typeof(ResponseResult<StudentGoalSummary>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ResponseResult<StudentGoalSummary>), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class dbProgressEventBenchmarkRow
|
||||||
|
{
|
||||||
|
public required Guid ProgressEventId { get; set; }
|
||||||
|
public required Guid BenchmarkId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace WinStudentGoalTracker.DataAccess;
|
||||||
|
|
||||||
|
public class dbProgressEventWithGoalRow
|
||||||
|
{
|
||||||
|
public required Guid ProgressEventId { get; set; }
|
||||||
|
public required Guid GoalId { get; set; }
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public DateTime? CreatedAt { get; set; }
|
||||||
|
public string? CreatedByName { get; set; }
|
||||||
|
}
|
||||||
@@ -367,6 +367,78 @@ public class StudentRepository
|
|||||||
return rowsAffected > 0;
|
return rowsAffected > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns a full student profile: student card, goals, benchmarks,
|
||||||
|
// progress events, and benchmark/event associations in one call.
|
||||||
|
// *****************************************************************
|
||||||
|
public async Task<StudentFullProfileResponse?> GetFullProfileAsync(Guid idStudent)
|
||||||
|
{
|
||||||
|
using var db = Connection;
|
||||||
|
using var multi = await db.QueryMultipleAsync(
|
||||||
|
"sp_Student_GetFullProfile",
|
||||||
|
new { p_id_student = idStudent.ToString() },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
// Result set 1: Student card
|
||||||
|
var student = await multi.ReadSingleOrDefaultAsync<StudentResponse>();
|
||||||
|
if (student is null) return null;
|
||||||
|
|
||||||
|
// Result set 2: Goals
|
||||||
|
var goalRows = (await multi.ReadAsync<dbStudentGoalRow>()).ToList();
|
||||||
|
|
||||||
|
// Result set 3: Benchmarks
|
||||||
|
var benchmarkRows = (await multi.ReadAsync<dbStudentBenchmarkRow>()).ToList();
|
||||||
|
|
||||||
|
// Result set 4: Progress events
|
||||||
|
var eventRows = (await multi.ReadAsync<dbProgressEventWithGoalRow>()).ToList();
|
||||||
|
|
||||||
|
// Result set 5: Benchmark/event associations
|
||||||
|
var linkRows = (await multi.ReadAsync<dbProgressEventBenchmarkRow>()).ToList();
|
||||||
|
|
||||||
|
return new StudentFullProfileResponse
|
||||||
|
{
|
||||||
|
Student = student,
|
||||||
|
Goals = goalRows.Select(r => new StudentGoalItem
|
||||||
|
{
|
||||||
|
GoalId = r.GoalId,
|
||||||
|
GoalParentId = r.GoalParentId,
|
||||||
|
Description = r.Description,
|
||||||
|
Category = r.Category,
|
||||||
|
Baseline = r.Baseline,
|
||||||
|
TargetCompletionDate = r.TargetCompletionDate,
|
||||||
|
CloseDate = r.CloseDate,
|
||||||
|
Achieved = r.Achieved,
|
||||||
|
CloseNotes = r.CloseNotes,
|
||||||
|
ProgressEventCount = r.ProgressEventCount,
|
||||||
|
BenchmarkCount = r.BenchmarkCount
|
||||||
|
}).ToList(),
|
||||||
|
Benchmarks = benchmarkRows.Select(r => new StudentBenchmarkItem
|
||||||
|
{
|
||||||
|
BenchmarkId = r.BenchmarkId,
|
||||||
|
GoalId = r.GoalId,
|
||||||
|
GoalCategory = r.GoalCategory,
|
||||||
|
Benchmark = r.Benchmark,
|
||||||
|
ShortName = r.ShortName,
|
||||||
|
CreatedByName = r.CreatedByName,
|
||||||
|
CreatedAt = r.CreatedAt,
|
||||||
|
UpdatedAt = r.UpdatedAt
|
||||||
|
}).ToList(),
|
||||||
|
ProgressEvents = eventRows.Select(r => new ProgressEventWithGoalResponse
|
||||||
|
{
|
||||||
|
ProgressEventId = r.ProgressEventId,
|
||||||
|
GoalId = r.GoalId,
|
||||||
|
Content = r.Content,
|
||||||
|
CreatedAt = r.CreatedAt,
|
||||||
|
CreatedByName = r.CreatedByName
|
||||||
|
}).ToList(),
|
||||||
|
ProgressEventBenchmarks = linkRows.Select(r => new ProgressEventBenchmarkLink
|
||||||
|
{
|
||||||
|
ProgressEventId = r.ProgressEventId,
|
||||||
|
BenchmarkId = r.BenchmarkId
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Returns a full progress report for a student within the given
|
// Returns a full progress report for a student within the given
|
||||||
// date range. Calls sp_ProgressReport_GetByStudentId which returns
|
// date range. Calls sp_ProgressReport_GetByStudentId which returns
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace WinStudentGoalTracker.Models;
|
||||||
|
|
||||||
|
public class StudentFullProfileResponse
|
||||||
|
{
|
||||||
|
public required StudentResponse Student { get; set; }
|
||||||
|
public List<StudentGoalItem> Goals { get; set; } = [];
|
||||||
|
public List<StudentBenchmarkItem> Benchmarks { get; set; } = [];
|
||||||
|
public List<ProgressEventWithGoalResponse> ProgressEvents { get; set; } = [];
|
||||||
|
public List<ProgressEventBenchmarkLink> ProgressEventBenchmarks { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProgressEventWithGoalResponse
|
||||||
|
{
|
||||||
|
public Guid ProgressEventId { get; set; }
|
||||||
|
public Guid GoalId { get; set; }
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public DateTime? CreatedAt { get; set; }
|
||||||
|
public string? CreatedByName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProgressEventBenchmarkLink
|
||||||
|
{
|
||||||
|
public Guid ProgressEventId { get; set; }
|
||||||
|
public Guid BenchmarkId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
DELIMITER ;;
|
||||||
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetFullProfile`(IN p_id_student CHAR(36))
|
||||||
|
BEGIN
|
||||||
|
-- Result set 1: Student card
|
||||||
|
SELECT
|
||||||
|
studentId,
|
||||||
|
identifier,
|
||||||
|
nextIepDate,
|
||||||
|
firstEntryDate,
|
||||||
|
lastEntryDate,
|
||||||
|
goalCount,
|
||||||
|
progressEventCount,
|
||||||
|
benchmarkCount
|
||||||
|
FROM v_student_card
|
||||||
|
WHERE studentId = p_id_student
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Result set 2: Goals
|
||||||
|
SELECT
|
||||||
|
s.`identifier` AS `studentIdentifier`,
|
||||||
|
vc.`goalId`,
|
||||||
|
vc.`goalParentId`,
|
||||||
|
vc.`description`,
|
||||||
|
vc.`category`,
|
||||||
|
vc.`baseline`,
|
||||||
|
vc.`targetCompletionDate`,
|
||||||
|
vc.`closeDate`,
|
||||||
|
vc.`achieved`,
|
||||||
|
vc.`closeNotes`,
|
||||||
|
vc.`progressEventCount`,
|
||||||
|
vc.`benchmarkCount`
|
||||||
|
FROM `v_goal_card` vc
|
||||||
|
INNER JOIN `student` s ON s.`id_student` = vc.`studentId`
|
||||||
|
WHERE vc.`studentId` = p_id_student
|
||||||
|
ORDER BY vc.`goalId`;
|
||||||
|
|
||||||
|
-- Result set 3: Benchmarks
|
||||||
|
SELECT
|
||||||
|
s.`identifier` AS `studentIdentifier`,
|
||||||
|
b.`id_benchmark` AS `benchmarkId`,
|
||||||
|
b.`id_goal` AS `goalId`,
|
||||||
|
g.`category` AS `goalCategory`,
|
||||||
|
b.`benchmark` AS `benchmark`,
|
||||||
|
b.`short_name` AS `shortName`,
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- Result set 4: Progress events (all goals for this student)
|
||||||
|
SELECT
|
||||||
|
vc.`progressEventId`,
|
||||||
|
vc.`goalId`,
|
||||||
|
vc.`content`,
|
||||||
|
vc.`createdAt`,
|
||||||
|
vc.`createdByName`
|
||||||
|
FROM `v_progress_event_card` vc
|
||||||
|
WHERE vc.`studentId` = p_id_student
|
||||||
|
ORDER BY vc.`createdAt` DESC;
|
||||||
|
|
||||||
|
-- Result set 5: Benchmark/progress-event associations
|
||||||
|
SELECT
|
||||||
|
peb.`id_progress_event` AS `progressEventId`,
|
||||||
|
peb.`id_benchmark` AS `benchmarkId`
|
||||||
|
FROM `progress_event_benchmark` peb
|
||||||
|
INNER JOIN `progress_event` pe ON pe.`id_progress_event` = peb.`id_progress_event`
|
||||||
|
INNER JOIN `goal` g ON g.`id_goal` = pe.`id_goal`
|
||||||
|
WHERE g.`id_student` = p_id_student;
|
||||||
|
END;;
|
||||||
|
DELIMITER ;
|
||||||
+6
-8
@@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ModalShell } from '../modal-shell/modal-shell';
|
import { ModalShell } from '../modal-shell/modal-shell';
|
||||||
import { StudentService } from '../../../shared/services/student.service';
|
import { StudentService } from '../../../shared/services/student.service';
|
||||||
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
|
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
|
||||||
import { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
|
import { ProgressEventWithGoalDto } from '../../../shared/classes/student-full-profile.dto';
|
||||||
import { GOAL_COLOR } from '../../../shared/classes/category-colors';
|
import { GOAL_COLOR } from '../../../shared/classes/category-colors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -19,8 +19,10 @@ export class EditEventModal {
|
|||||||
readonly goalId = input.required<string>();
|
readonly goalId = input.required<string>();
|
||||||
|
|
||||||
readonly benchmarks = input<BenchmarkDto[]>([]);
|
readonly benchmarks = input<BenchmarkDto[]>([]);
|
||||||
|
/** Benchmark IDs already associated with this event (from cached profile). */
|
||||||
|
readonly eventBenchmarkIds = input<string[]>([]);
|
||||||
/** null for new event, populated for edit */
|
/** null for new event, populated for edit */
|
||||||
readonly event = input<ProgressEventDto | null>(null);
|
readonly event = input<ProgressEventWithGoalDto | null>(null);
|
||||||
readonly saved = output<void>();
|
readonly saved = output<void>();
|
||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
|
|
||||||
@@ -30,15 +32,11 @@ export class EditEventModal {
|
|||||||
|
|
||||||
protected content = '';
|
protected content = '';
|
||||||
|
|
||||||
async ngOnInit() {
|
ngOnInit() {
|
||||||
const ev = this.event();
|
const ev = this.event();
|
||||||
if (ev) {
|
if (ev) {
|
||||||
this.content = ev.content;
|
this.content = ev.content;
|
||||||
// Load existing benchmark associations
|
this.selectedBenchmarkIds.set(new Set(this.eventBenchmarkIds()));
|
||||||
const result = await this.studentService.getProgressEventBenchmarks(ev.progressEventId);
|
|
||||||
if (result.success && result.payload) {
|
|
||||||
this.selectedBenchmarkIds.set(new Set(result.payload));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
@if (showEditEventModal()) {
|
@if (showEditEventModal()) {
|
||||||
<app-edit-event-modal [studentId]="studentId()!" [goalId]="selectedGoal()!.goalId"
|
<app-edit-event-modal [studentId]="studentId()!" [goalId]="selectedGoal()!.goalId"
|
||||||
[benchmarks]="goalBenchmarks()"
|
[benchmarks]="goalBenchmarks()"
|
||||||
|
[eventBenchmarkIds]="showEditEventModal() !== 'new' && showEditEventModal() ? getBenchmarkIdsForEvent($any(showEditEventModal()).progressEventId) : []"
|
||||||
[event]="showEditEventModal() === 'new' ? null : $any(showEditEventModal())" (saved)="onEventSaved()"
|
[event]="showEditEventModal() === 'new' ? null : $any(showEditEventModal())" (saved)="onEventSaved()"
|
||||||
(closed)="showEditEventModal.set(null)" />
|
(closed)="showEditEventModal.set(null)" />
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="sub-tab" [class.active]="activeTab() === 'progress'"
|
<button class="sub-tab" [class.active]="activeTab() === 'progress'"
|
||||||
(click)="onTabChange('progress')">
|
(click)="onTabChange('progress')">
|
||||||
Progress Events ({{ sortedProgressEvents().length }})
|
Progress Events ({{ goalProgressEvents().length }})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
<div class="tab-content timeline">
|
<div class="tab-content timeline">
|
||||||
<button class="add-btn" (click)="onNewEvent()">+ Log Progress Event</button>
|
<button class="add-btn" (click)="onNewEvent()">+ Log Progress Event</button>
|
||||||
<div class="timeline-line"></div>
|
<div class="timeline-line"></div>
|
||||||
@for (ev of sortedProgressEvents(); track ev.progressEventId) {
|
@for (ev of goalProgressEvents(); track ev.progressEventId) {
|
||||||
<div class="timeline-item">
|
<div class="timeline-item">
|
||||||
<div class="timeline-dot"></div>
|
<div class="timeline-dot"></div>
|
||||||
<div class="event-card">
|
<div class="event-card">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { StudentService } from '../../../shared/services/student.service';
|
|||||||
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
|
import { StudentCardDto } from '../../../shared/classes/student-card.dto';
|
||||||
import { StudentGoalItem } from '../../../shared/classes/student-goal';
|
import { StudentGoalItem } from '../../../shared/classes/student-goal';
|
||||||
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
|
import { BenchmarkDto } from '../../../shared/classes/benchmark.dto';
|
||||||
import { ProgressEventDto } from '../../../shared/classes/progress-event.dto';
|
import { StudentFullProfileDto, ProgressEventWithGoalDto, ProgressEventBenchmarkLink } from '../../../shared/classes/student-full-profile.dto';
|
||||||
import { GoalModal } from '../goal-modal/goal-modal';
|
import { GoalModal } from '../goal-modal/goal-modal';
|
||||||
import { EditBenchmarkModal } from '../edit-benchmark-modal/edit-benchmark-modal';
|
import { EditBenchmarkModal } from '../edit-benchmark-modal/edit-benchmark-modal';
|
||||||
import { EditEventModal } from '../edit-event-modal/edit-event-modal';
|
import { EditEventModal } from '../edit-event-modal/edit-event-modal';
|
||||||
@@ -50,6 +50,7 @@ export class Workspace {
|
|||||||
if (initialized) {
|
if (initialized) {
|
||||||
const id = untracked(() => this.studentId());
|
const id = untracked(() => this.studentId());
|
||||||
if (id) {
|
if (id) {
|
||||||
|
this.studentService.invalidateProfile(id);
|
||||||
this.loadStudentData(id);
|
this.loadStudentData(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,14 +68,15 @@ export class Workspace {
|
|||||||
protected readonly student = signal<StudentCardDto | null>(null);
|
protected readonly student = signal<StudentCardDto | null>(null);
|
||||||
protected readonly goals = signal<StudentGoalItem[]>([]);
|
protected readonly goals = signal<StudentGoalItem[]>([]);
|
||||||
protected readonly benchmarks = signal<BenchmarkDto[]>([]);
|
protected readonly benchmarks = signal<BenchmarkDto[]>([]);
|
||||||
protected readonly progressEvents = signal<ProgressEventDto[]>([]);
|
protected readonly progressEvents = signal<ProgressEventWithGoalDto[]>([]);
|
||||||
|
protected readonly progressEventBenchmarks = signal<ProgressEventBenchmarkLink[]>([]);
|
||||||
protected readonly selectedGoalId = signal<string | null>(null);
|
protected readonly selectedGoalId = signal<string | null>(null);
|
||||||
protected readonly activeTab = signal<TabView>('benchmarks');
|
protected readonly activeTab = signal<TabView>('benchmarks');
|
||||||
|
|
||||||
// Modal states
|
// Modal states
|
||||||
protected readonly showGoalModal = signal<StudentGoalItem | 'add' | null>(null);
|
protected readonly showGoalModal = signal<StudentGoalItem | 'add' | null>(null);
|
||||||
protected readonly showEditBenchmarkModal = signal<BenchmarkDto | 'new' | null>(null);
|
protected readonly showEditBenchmarkModal = signal<BenchmarkDto | 'new' | null>(null);
|
||||||
protected readonly showEditEventModal = signal<ProgressEventDto | null | 'new'>(null);
|
protected readonly showEditEventModal = signal<ProgressEventWithGoalDto | null | 'new'>(null);
|
||||||
|
|
||||||
// ************************** Properties ***************************
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
@@ -91,8 +93,11 @@ export class Workspace {
|
|||||||
return this.benchmarks().filter(b => b.goalId === goalId);
|
return this.benchmarks().filter(b => b.goalId === goalId);
|
||||||
});
|
});
|
||||||
|
|
||||||
protected readonly sortedProgressEvents = computed<ProgressEventDto[]>(() => {
|
protected readonly goalProgressEvents = computed<ProgressEventWithGoalDto[]>(() => {
|
||||||
return [...this.progressEvents()]
|
const goalId = this.selectedGoal()?.goalId;
|
||||||
|
if (!goalId) return [];
|
||||||
|
return this.progressEvents()
|
||||||
|
.filter(e => e.goalId === goalId)
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +112,6 @@ export class Workspace {
|
|||||||
onSelectGoal(goalId: string) {
|
onSelectGoal(goalId: string) {
|
||||||
this.selectedGoalId.set(goalId);
|
this.selectedGoalId.set(goalId);
|
||||||
this.activeTab.set('benchmarks');
|
this.activeTab.set('benchmarks');
|
||||||
this.loadGoalDetails(goalId);
|
|
||||||
this.router.navigate(['/students', this.studentId(), 'goals', goalId]);
|
this.router.navigate(['/students', this.studentId(), 'goals', goalId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +126,7 @@ export class Workspace {
|
|||||||
|
|
||||||
onGoalSaved() {
|
onGoalSaved() {
|
||||||
this.showGoalModal.set(null);
|
this.showGoalModal.set(null);
|
||||||
this.loadStudentData(this.studentId()!);
|
this.refetchProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddGoal() {
|
onAddGoal() {
|
||||||
@@ -132,9 +136,8 @@ export class Workspace {
|
|||||||
onGoalCreated(goal: StudentGoalItem) {
|
onGoalCreated(goal: StudentGoalItem) {
|
||||||
this.showGoalModal.set(null);
|
this.showGoalModal.set(null);
|
||||||
this.studentService.notifyDataChanged();
|
this.studentService.notifyDataChanged();
|
||||||
this.loadStudentData(this.studentId()!).then(() => {
|
this.refetchProfile().then(() => {
|
||||||
this.selectedGoalId.set(goal.goalId);
|
this.selectedGoalId.set(goal.goalId);
|
||||||
this.loadGoalDetails(goal.goalId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +147,7 @@ export class Workspace {
|
|||||||
|
|
||||||
onEditBenchmarkSaved() {
|
onEditBenchmarkSaved() {
|
||||||
this.showEditBenchmarkModal.set(null);
|
this.showEditBenchmarkModal.set(null);
|
||||||
this.loadStudentData(this.studentId()!);
|
this.refetchProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddBenchmark() {
|
onAddBenchmark() {
|
||||||
@@ -155,15 +158,23 @@ export class Workspace {
|
|||||||
this.showEditEventModal.set('new');
|
this.showEditEventModal.set('new');
|
||||||
}
|
}
|
||||||
|
|
||||||
onEditEvent(ev: ProgressEventDto) {
|
onEditEvent(ev: ProgressEventWithGoalDto) {
|
||||||
this.showEditEventModal.set(ev);
|
this.showEditEventModal.set(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEventSaved() {
|
onEventSaved() {
|
||||||
this.showEditEventModal.set(null);
|
this.showEditEventModal.set(null);
|
||||||
if (this.selectedGoal()) {
|
this.refetchProfile();
|
||||||
this.loadGoalDetails(this.selectedGoal()!.goalId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns the benchmark IDs associated with a given progress event,
|
||||||
|
// read from the cached profile data.
|
||||||
|
// *****************************************************************
|
||||||
|
getBenchmarkIdsForEvent(progressEventId: string): string[] {
|
||||||
|
return this.progressEventBenchmarks()
|
||||||
|
.filter(link => link.progressEventId === progressEventId)
|
||||||
|
.map(link => link.benchmarkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ************************ Formatting Helpers **********************
|
// ************************ Formatting Helpers **********************
|
||||||
@@ -177,39 +188,27 @@ export class Workspace {
|
|||||||
// ********************** Support Procedures ***********************
|
// ********************** Support Procedures ***********************
|
||||||
|
|
||||||
private async loadStudentData(studentId: string) {
|
private async loadStudentData(studentId: string) {
|
||||||
const [studentResult, goalsResult, bmResult] = await Promise.all([
|
const result = await this.studentService.getFullProfile(studentId);
|
||||||
this.studentService.getStudentById(studentId),
|
|
||||||
this.studentService.getGoalsForStudent(studentId),
|
|
||||||
this.studentService.getBenchmarksForStudent(studentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (studentResult.success && studentResult.payload) {
|
if (!result.success || !result.payload) return;
|
||||||
this.student.set(studentResult.payload);
|
|
||||||
}
|
const profile = result.payload;
|
||||||
|
this.student.set(profile.student);
|
||||||
|
this.goals.set(profile.goals);
|
||||||
|
this.benchmarks.set(profile.benchmarks);
|
||||||
|
this.progressEvents.set(profile.progressEvents);
|
||||||
|
this.progressEventBenchmarks.set(profile.progressEventBenchmarks);
|
||||||
|
|
||||||
if (goalsResult.success && goalsResult.payload) {
|
|
||||||
this.goals.set(goalsResult.payload.goals);
|
|
||||||
// Auto-select first goal if none selected
|
// Auto-select first goal if none selected
|
||||||
if (!this.selectedGoalId() && goalsResult.payload.goals.length > 0) {
|
if (!this.selectedGoalId() && profile.goals.length > 0) {
|
||||||
this.selectedGoalId.set(goalsResult.payload.goals[0].goalId);
|
this.selectedGoalId.set(profile.goals[0].goalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bmResult.success && bmResult.payload) {
|
private async refetchProfile(): Promise<void> {
|
||||||
this.benchmarks.set(bmResult.payload.benchmarks);
|
const id = this.studentId();
|
||||||
}
|
if (!id) return;
|
||||||
|
this.studentService.invalidateProfile(id);
|
||||||
// Load progress events for selected goal
|
await this.loadStudentData(id);
|
||||||
const goalId = this.selectedGoalId();
|
|
||||||
if (goalId) {
|
|
||||||
this.loadGoalDetails(goalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadGoalDetails(goalId: string) {
|
|
||||||
const result = await this.studentService.getProgressEventsForGoal(goalId);
|
|
||||||
if (result.success) {
|
|
||||||
this.progressEvents.set(result.payload ?? []);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { StudentCardDto } from './student-card.dto';
|
||||||
|
import { StudentGoalItem } from './student-goal';
|
||||||
|
import { BenchmarkDto } from './benchmark.dto';
|
||||||
|
|
||||||
|
export interface StudentFullProfileDto {
|
||||||
|
student: StudentCardDto;
|
||||||
|
goals: StudentGoalItem[];
|
||||||
|
benchmarks: BenchmarkDto[];
|
||||||
|
progressEvents: ProgressEventWithGoalDto[];
|
||||||
|
progressEventBenchmarks: ProgressEventBenchmarkLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressEventWithGoalDto {
|
||||||
|
progressEventId: string;
|
||||||
|
goalId: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: Date;
|
||||||
|
createdByName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressEventBenchmarkLink {
|
||||||
|
progressEventId: string;
|
||||||
|
benchmarkId: string;
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ import { CreateStudentDto } from '../classes/create-student.dto';
|
|||||||
import { CreateGoalDto } from '../classes/create-goal.dto';
|
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 { StudentBenchmarkSummary } from '../classes/benchmark.dto';
|
import { StudentBenchmarkSummary } from '../classes/benchmark.dto';
|
||||||
import { StudentProgressReportDto } from '../classes/student-progress-report.dto';
|
import { StudentProgressReportDto } from '../classes/student-progress-report.dto';
|
||||||
|
import { StudentFullProfileDto } from '../classes/student-full-profile.dto';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -28,6 +28,9 @@ export class StudentService {
|
|||||||
// Incremented after any data mutation so subscribers can refresh.
|
// Incremented after any data mutation so subscribers can refresh.
|
||||||
readonly dataVersion = signal(0);
|
readonly dataVersion = signal(0);
|
||||||
|
|
||||||
|
// Per-student full profile cache.
|
||||||
|
private readonly profileCache = new Map<string, StudentFullProfileDto>();
|
||||||
|
|
||||||
// Emits targeted label updates for sidebar nodes without a full rebuild.
|
// Emits targeted label updates for sidebar nodes without a full rebuild.
|
||||||
private readonly _sidebarLabelUpdate = new Subject<{ routerLink: string[]; label: string }>();
|
private readonly _sidebarLabelUpdate = new Subject<{ routerLink: string[]; label: string }>();
|
||||||
readonly sidebarLabelUpdate$ = this._sidebarLabelUpdate.asObservable();
|
readonly sidebarLabelUpdate$ = this._sidebarLabelUpdate.asObservable();
|
||||||
@@ -43,6 +46,41 @@ export class StudentService {
|
|||||||
this.dataVersion.update(v => v + 1);
|
this.dataVersion.update(v => v + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Returns the full profile for a student. Uses a per-student cache
|
||||||
|
// so subsequent loads are instant. Call invalidateProfile() after
|
||||||
|
// mutations to force a fresh fetch.
|
||||||
|
// *****************************************************************
|
||||||
|
async getFullProfile(studentId: string): Promise<ApiResult<StudentFullProfileDto>> {
|
||||||
|
const cached = this.profileCache.get(studentId);
|
||||||
|
if (cached) return ApiResult.ok(cached);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.get<ResponseResult<StudentFullProfileDto>>(`${this.base}/api/Student/${studentId}/full`)
|
||||||
|
);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
this.profileCache.set(studentId, result.data);
|
||||||
|
return ApiResult.ok(result.data);
|
||||||
|
}
|
||||||
|
return ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Removes a student's cached profile so the next getFullProfile
|
||||||
|
// call fetches fresh data. Pass no argument to clear all.
|
||||||
|
// *****************************************************************
|
||||||
|
invalidateProfile(studentId?: string) {
|
||||||
|
if (studentId) {
|
||||||
|
this.profileCache.delete(studentId);
|
||||||
|
} else {
|
||||||
|
this.profileCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Emits a targeted sidebar label update for a specific node,
|
// Emits a targeted sidebar label update for a specific node,
|
||||||
// avoiding the full tree rebuild that notifyDataChanged triggers.
|
// avoiding the full tree rebuild that notifyDataChanged triggers.
|
||||||
@@ -152,38 +190,6 @@ export class StudentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
|
||||||
// Returns benchmark IDs associated with a progress event.
|
|
||||||
// *****************************************************************
|
|
||||||
async getProgressEventBenchmarks(progressEventId: string): Promise<ApiResult<string[]>> {
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(
|
|
||||||
this.http.get<ResponseResult<string[]>>(`${this.base}/api/Student/progress-events/${progressEventId}/benchmarks`)
|
|
||||||
);
|
|
||||||
return result.success
|
|
||||||
? ApiResult.ok(result.data ?? [])
|
|
||||||
: ApiResult.fail(result.message);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// *****************************************************************
|
|
||||||
// Returns progress events for a given student goal.
|
|
||||||
// *****************************************************************
|
|
||||||
async getProgressEventsForGoal(goalId: string): Promise<ApiResult<ProgressEventDto[]>> {
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(
|
|
||||||
this.http.get<ResponseResult<ProgressEventDto[]>>(`${this.base}/api/Student/goals/${goalId}/progress-events`)
|
|
||||||
);
|
|
||||||
return result.success
|
|
||||||
? ApiResult.ok(result.data ?? [])
|
|
||||||
: ApiResult.fail(result.message);
|
|
||||||
} catch (error) {
|
|
||||||
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Returns a full progress report for a student within a date
|
// Returns a full progress report for a student within a date
|
||||||
// range, including goals, events, and benchmark associations.
|
// range, including goals, events, and benchmark associations.
|
||||||
@@ -211,22 +217,6 @@ export class StudentService {
|
|||||||
|
|
||||||
// ********************** 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.
|
// Updates a student and returns the refreshed student data.
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
|
|||||||
Reference in New Issue
Block a user