diff --git a/api/src/Controllers/GoalController.cs b/api/src/Controllers/GoalController.cs deleted file mode 100644 index e69de29..0000000 diff --git a/api/src/Controllers/StudentController.cs b/api/src/Controllers/StudentController.cs index 494db80..11f12f6 100644 --- a/api/src/Controllers/StudentController.cs +++ b/api/src/Controllers/StudentController.cs @@ -130,6 +130,57 @@ public class StudentController : BaseController }); } + [HttpPost("{idStudent:guid}/goals")] + [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.ProgramAdmin}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] + public async Task>> CreateGoal(Guid idStudent, [FromBody] CreateGoalDto 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 + { + Success = false, + Message = "Student not found." + }); + } + + if (!PermissionService.IsAllowed(role, EntityType.Goal, PermissionAction.Create)) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Unable to create goal." + }); + } + + var created = await _studentRepository.InsertGoalAsync(idStudent, userId, dto); + if (created is null) + { + return BadRequest(new ResponseResult + { + Success = false, + Message = "Unable to create goal." + }); + } + + return StatusCode(StatusCodes.Status201Created, new ResponseResult + { + Success = true, + Message = "Goal created successfully.", + Data = created + }); + } + [HttpPost("{idStudent:guid}/progress-event")] [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status201Created)] @@ -216,7 +267,7 @@ public class StudentController : BaseController [Authorize(Roles = $"{UserRoles.Teacher}")] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] - public async Task>> Update(Guid idStudent, [FromBody] UpdateStudentDto request) + public async Task>> UpdateStudent(Guid idStudent, [FromBody] UpdateStudentDto request) { var (userId, email, programId, role, error) = GetProgramUserFromClaims(); if (error is not null) diff --git a/api/src/DataAccess/Models/DataTransferObjects/CreateGoalDto.cs b/api/src/DataAccess/Models/DataTransferObjects/CreateGoalDto.cs new file mode 100644 index 0000000..be3c3ec --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/CreateGoalDto.cs @@ -0,0 +1,8 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class CreateGoalDto +{ + public string? Title { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } +} diff --git a/api/src/DataAccess/Repositories/StudentRepository.cs b/api/src/DataAccess/Repositories/StudentRepository.cs index c985e3f..126d70e 100644 --- a/api/src/DataAccess/Repositories/StudentRepository.cs +++ b/api/src/DataAccess/Repositories/StudentRepository.cs @@ -101,6 +101,36 @@ public class StudentRepository return rowsAffected > 0; } + public async Task InsertGoalAsync(Guid idStudent, Guid userId, CreateGoalDto dto) + { + var newGoalId = Guid.NewGuid(); + using var db = Connection; + var rowsAffected = await db.ExecuteAsync( + "sp_Goal_Insert", + new + { + p_id_goal = newGoalId.ToString(), + p_id_student = idStudent.ToString(), + p_id_user = userId.ToString(), + p_title = dto.Title, + p_description = dto.Description, + p_category = dto.Category + }, + commandType: CommandType.StoredProcedure); + + if (rowsAffected == 0) return null; + + return new StudentGoalItem + { + GoalId = newGoalId, + GoalParentId = null, + Title = dto.Title, + Description = dto.Description, + Category = dto.Category, + ProgressEventCount = 0 + }; + } + public async Task GetGoalSummaryAsync(Guid idStudent) { using var db = Connection; @@ -110,7 +140,17 @@ public class StudentRepository commandType: CommandType.StoredProcedure); var list = rows.ToList(); - if (list.Count == 0) return null; + if (list.Count == 0) + { + var student = await GetByIdAsync(idStudent); + if (student is null) return null; + + return new StudentGoalSummary + { + StudentIdentifier = student.Identifier, + Goals = [] + }; + } return new StudentGoalSummary { diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.html b/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.html new file mode 100644 index 0000000..3af25da --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.html @@ -0,0 +1,58 @@ +
+ + diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.scss b/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.scss new file mode 100644 index 0000000..b307370 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.scss @@ -0,0 +1,134 @@ +:host { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); +} + +.modal { + position: relative; + background: #fff; + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); + width: 420px; + max-width: 95vw; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem 0; +} + +.modal-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.close-btn { + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + color: #666; + padding: 0; +} + +.close-btn:hover { + color: #111; +} + +.modal-body { + padding: 1.25rem 1.5rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.field label { + font-size: 0.875rem; + font-weight: 500; + color: #333; +} + +.field input, +.field textarea { + padding: 0.5rem 0.75rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.9375rem; + font-family: inherit; + outline: none; + resize: vertical; +} + +.field input:focus, +.field textarea:focus { + border-color: #4f46e5; + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15); +} + +.error { + font-size: 0.875rem; + color: #dc2626; + margin: 0; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.25rem; +} + +.btn { + padding: 0.5rem 1.125rem; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; +} + +.btn-secondary { + background: transparent; + border-color: #ddd; + color: #555; +} + +.btn-secondary:hover { + background: #f5f5f5; +} + +.btn-primary { + background: #4f46e5; + color: #fff; + border-color: #4f46e5; +} + +.btn-primary:hover:not(:disabled) { + background: #4338ca; + border-color: #4338ca; +} + +.btn-primary:disabled { + opacity: 0.55; + cursor: not-allowed; +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.ts b/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.ts new file mode 100644 index 0000000..17297e4 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/add-goal-modal/add-goal-modal.ts @@ -0,0 +1,61 @@ +import { Component, inject, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { CreateGoalDto } from '../../../shared/classes/create-goal.dto'; +import { StudentGoalItem } from '../../../shared/classes/student-goal'; +import { StudentService } from '../../../shared/services/student.service'; + +@Component({ + selector: 'app-add-goal-modal', + imports: [FormsModule], + templateUrl: './add-goal-modal.html', + styleUrl: './add-goal-modal.scss', +}) +export class AddGoalModal { + + // ************************** Constructor ************************** + + // ************************** Declarations ************************* + + private readonly studentService = inject(StudentService); + + readonly studentId = input.required(); + readonly goalCreated = output(); + readonly cancelled = output(); + + protected readonly isSubmitting = signal(false); + protected readonly errorMessage = signal(null); + + protected form: CreateGoalDto = { + title: '', + description: '', + category: '', + }; + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + async onSubmit() { + this.errorMessage.set(null); + this.isSubmitting.set(true); + + const result = await this.studentService.createGoal(this.studentId(), this.form); + + this.isSubmitting.set(false); + + if (!result.success) { + this.errorMessage.set(result.message); + return; + } + + this.goalCreated.emit(result.payload!); + } + + onCancel() { + this.cancelled.emit(); + } + + // ********************** Support Procedures *********************** +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.html b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.html new file mode 100644 index 0000000..0e89ac2 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.html @@ -0,0 +1,9 @@ +
+
+ {{ goal().category }} + {{ goal().progressEventCount }} events +
+ +

{{ goal().title }}

+

{{ goal().description }}

+
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.scss b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.scss new file mode 100644 index 0000000..21c6766 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.scss @@ -0,0 +1,48 @@ +: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; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.category-badge { + padding: 0.2rem 0.6rem; + background: #eef2ff; + color: #4f46e5; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} + +.event-count { + font-size: 0.8125rem; + color: #888; +} + +.title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #111; +} + +.description { + margin: 0; + font-size: 0.875rem; + color: #555; + line-height: 1.5; +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.ts new file mode 100644 index 0000000..9070017 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-card/goal-card.ts @@ -0,0 +1,25 @@ +import { Component, input } from '@angular/core'; +import { StudentGoalItem } from '../../../shared/classes/student-goal'; + +@Component({ + selector: 'app-goal-card', + imports: [], + templateUrl: './goal-card.html', + styleUrl: './goal-card.scss', +}) +export class GoalCard { + + // ************************** Constructor ************************** + + // ************************** Declarations ************************* + + readonly goal = input.required(); + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + // ********************** Support Procedures *********************** +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.html b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.html new file mode 100644 index 0000000..6f721bb --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.html @@ -0,0 +1,26 @@ +
+ + @if (studentIdentifier()) { + {{ studentIdentifier() }} + } + + +
+ +@if (showAddModal()) { + +} + +@if (errorMessage()) { +

{{ errorMessage() }}

+} + +
+ @for (goal of goals(); track goal.goalId) { + + } +
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.scss b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.scss new file mode 100644 index 0000000..0007838 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.scss @@ -0,0 +1,61 @@ +: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; +} + +.student-label { + font-size: 0.9375rem; + font-weight: 600; + color: #333; +} + +.spacer { + flex: 1; +} + +.error { + font-size: 0.875rem; + color: #dc2626; + margin: 0 0 1rem; +} + +.card-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + overflow-y: auto; + flex: 1; +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.ts b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.ts new file mode 100644 index 0000000..d683d7b --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/goal-list/goal-list.ts @@ -0,0 +1,73 @@ +import { Component, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { StudentGoalItem } from '../../../shared/classes/student-goal'; +import { StudentService } from '../../../shared/services/student.service'; +import { GoalCard } from '../goal-card/goal-card'; +import { AddGoalModal } from '../add-goal-modal/add-goal-modal'; + +@Component({ + selector: 'app-goal-list', + imports: [GoalCard, AddGoalModal], + templateUrl: './goal-list.html', + styleUrl: './goal-list.scss', +}) +export class GoalList { + + // ************************** Constructor ************************** + + constructor() { + this.studentId = this.route.snapshot.paramMap.get('studentId')!; + this.loadGoals(); + } + + // ************************** Declarations ************************* + + private readonly studentService = inject(StudentService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly studentId: string; + protected readonly studentIdentifier = signal(null); + protected readonly goals = signal([]); + protected readonly showAddModal = signal(false); + protected readonly errorMessage = signal(null); + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + onAddGoal() { + this.showAddModal.set(true); + } + + onGoalCreated(goal: StudentGoalItem) { + this.goals.update(list => [...list, goal]); + this.showAddModal.set(false); + } + + onModalCancelled() { + this.showAddModal.set(false); + } + + onBack() { + this.router.navigate(['/students']); + } + + // ********************** Support Procedures *********************** + + // ***************************************************************** + // Loads goals for the student from the service. + // ***************************************************************** + private loadGoals() { + this.studentService.getGoalsForStudent(this.studentId).then(data => { + if (!data.success) { + this.errorMessage.set(data.message); + } else { + this.studentIdentifier.set(data.payload?.studentIdentifier ?? null); + this.goals.set(data.payload?.goals ?? []); + } + }); + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.html b/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.html index e8eef1a..3557cb0 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.html @@ -1,4 +1,4 @@ -
+

🎓 {{ student().identifier }}

diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.scss b/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.scss index d5af2c1..a5e65bb 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.scss @@ -8,6 +8,13 @@ border-radius: 8px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); padding: 1.5rem; + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.15s ease; +} + +.card:hover { + box-shadow: 0 4px 20px rgba(79, 70, 229, 0.15); + transform: translateY(-2px); } .identifier { diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.ts b/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.ts index 9e766f3..de6f77d 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-card/student-card.ts @@ -1,10 +1,11 @@ import { Component, computed, input } from '@angular/core'; import { DatePipe } from '@angular/common'; +import { RouterLink } from '@angular/router'; import { StudentCardDto } from '../../../shared/classes/student-card.dto'; @Component({ selector: 'app-student-card', - imports: [DatePipe], + imports: [DatePipe, RouterLink], templateUrl: './student-card.html', styleUrl: './student-card.scss', }) diff --git a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts index 47663c4..5ba5a31 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { Home } from './pages/home/home'; import { StudentCardList } from './components/student-card-list/student-card-list'; +import { GoalList } from './components/goal-list/goal-list'; export default [ { @@ -9,6 +10,7 @@ export default [ children: [ { path: '', redirectTo: 'students', pathMatch: 'full' }, { path: 'students', component: StudentCardList }, + { path: 'students/:studentId/goals', component: GoalList }, ], }, ] satisfies Routes; diff --git a/ui/winstudentgoaltracker/src/app/shared/classes/create-goal.dto.ts b/ui/winstudentgoaltracker/src/app/shared/classes/create-goal.dto.ts new file mode 100644 index 0000000..8d8f55e --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/classes/create-goal.dto.ts @@ -0,0 +1,5 @@ +export interface CreateGoalDto { + title: string; + description: string; + category: string; +} diff --git a/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts index ef3b7a0..cf8c8c8 100644 --- a/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts +++ b/ui/winstudentgoaltracker/src/app/shared/services/dummy-student.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@angular/core'; import { StudentCardDto } from '../classes/student-card.dto'; import { ApiResult } from '../classes/api-result'; -import { StudentGoalSummary } from '../classes/student-goal'; +import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal'; +import { CreateGoalDto } from '../classes/create-goal.dto'; @Injectable({ providedIn: 'root', @@ -105,6 +106,25 @@ export class DummyStudentService { } + async createGoal(studentId: string, data: CreateGoalDto): Promise> { + const student = this.data[studentId]; + if (!student) { + return ApiResult.fail('Student not found'); + } + + const newGoal: StudentGoalItem = { + goalId: `g${Date.now()}`, + goalParentId: null, + title: data.title, + description: data.description, + category: data.category, + progressEventCount: 0, + }; + + student.goals.push(newGoal); + return ApiResult.ok(newGoal); + } + async addProgressEvent(studentId: string, goalId: string, content: string): Promise { return ApiResult.empty(); } diff --git a/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts index f225aba..ef5dbab 100644 --- a/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts +++ b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts @@ -6,8 +6,9 @@ import { ApiResult } from '../classes/api-result'; import { ResponseResult } from '../classes/auth.models'; import { describeHttpError } from '../classes/http-errors'; import { CreateStudentDto } from '../classes/create-student.dto'; +import { CreateGoalDto } from '../classes/create-goal.dto'; import { StudentCardDto } from '../classes/student-card.dto'; -import { StudentGoalSummary } from '../classes/student-goal'; +import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal'; @Injectable({ providedIn: 'root', @@ -73,6 +74,22 @@ export class StudentService { } } + // ***************************************************************** + // Creates a new goal for a student and returns the created goal. + // ***************************************************************** + async createGoal(studentId: string, data: CreateGoalDto): Promise> { + try { + const result = await firstValueFrom( + this.http.post>(`${this.base}/api/Student/${studentId}/goals`, data) + ); + return result.success && result.data + ? ApiResult.ok(result.data) + : ApiResult.fail(result.message); + } catch (error) { + return ApiResult.fail(describeHttpError(error as HttpErrorResponse)); + } + } + async addProgressEvent(studentId: string, goalId: string, content: string): Promise { try { const result = await firstValueFrom(