diff --git a/.gitignore b/.gitignore index 91c9ff8..02287c1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ /prototype/RolesAssignments/.vs /prototype/RolesAssignments/bin /prototype/RolesAssignments/obj +/.vs/WinStudentGoalTracker/CopilotIndices +/.vs/WinStudentGoalTracker/FileContentIndex +/.vs/WinStudentGoalTracker/DesignTimeBuild +/.vs/WinStudentGoalTracker/v17 diff --git a/db/Objects/procedures/sp_Student_Delete.sql b/db/Objects/procedures/sp_Student_Delete.sql index e4d6f24..811a21d 100644 --- a/db/Objects/procedures/sp_Student_Delete.sql +++ b/db/Objects/procedures/sp_Student_Delete.sql @@ -1,6 +1,8 @@ DELIMITER ;; CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36)) BEGIN + DELETE FROM user_student + WHERE id_student = p_id_student; DELETE FROM student WHERE id_student = p_id_student; SELECT ROW_COUNT() AS rows_affected; diff --git a/db/Objects/procedures/sp_Student_GetAll.sql b/db/Objects/procedures/sp_Student_GetAll.sql index d421313..f0b31cf 100644 --- a/db/Objects/procedures/sp_Student_GetAll.sql +++ b/db/Objects/procedures/sp_Student_GetAll.sql @@ -4,7 +4,6 @@ BEGIN SELECT id_student, id_program, - id_user, identifier, program_year, enrollment_date, diff --git a/db/Objects/procedures/sp_Student_GetById.sql b/db/Objects/procedures/sp_Student_GetById.sql index 1e9b742..3017116 100644 --- a/db/Objects/procedures/sp_Student_GetById.sql +++ b/db/Objects/procedures/sp_Student_GetById.sql @@ -4,7 +4,6 @@ BEGIN SELECT id_student, id_program, - id_user, identifier, program_year, enrollment_date, diff --git a/db/Objects/procedures/sp_Student_GetByProgram.sql b/db/Objects/procedures/sp_Student_GetByProgram.sql deleted file mode 100644 index 5e70f9e..0000000 --- a/db/Objects/procedures/sp_Student_GetByProgram.sql +++ /dev/null @@ -1,19 +0,0 @@ -DELIMITER ;; -CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByProgram`( - IN p_id_program CHAR(36) -) -BEGIN - SELECT - id_student, - id_program, - id_user, - identifier, - program_year, - enrollment_date, - expected_grad, - created_at - FROM student - WHERE id_program = p_id_program - ORDER BY id_student; -END;; -DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_GetByUserAndProgram.sql b/db/Objects/procedures/sp_Student_GetByUserAndProgram.sql deleted file mode 100644 index 13e28ac..0000000 --- a/db/Objects/procedures/sp_Student_GetByUserAndProgram.sql +++ /dev/null @@ -1,21 +0,0 @@ -DELIMITER ;; -CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByUserAndProgram`( - IN p_id_user CHAR(36), - IN p_id_program CHAR(36) -) -BEGIN - SELECT - s.id_student, - s.id_program, - s.id_user, - s.identifier, - s.program_year, - s.enrollment_date, - s.expected_grad, - s.created_at - FROM student s - WHERE s.id_user = p_id_user - AND s.id_program = p_id_program - ORDER BY s.id_student; -END;; -DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_GetWithAssignments.sql b/db/Objects/procedures/sp_Student_GetWithAssignments.sql new file mode 100644 index 0000000..cd2a4e3 --- /dev/null +++ b/db/Objects/procedures/sp_Student_GetWithAssignments.sql @@ -0,0 +1,30 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetWithAssignments`( + IN p_id_program CHAR(36), + IN p_id_user CHAR(36) +) +BEGIN + -- Result set 1: All students in the program + SELECT + s.id_student, + s.id_program, + s.identifier, + s.program_year, + s.enrollment_date, + s.expected_grad, + s.created_at + FROM student s + WHERE s.id_program = p_id_program + ORDER BY s.id_student; + -- Result set 2: user_student assignments for the requesting user in this program + SELECT + us.id_user_student, + us.id_user, + us.id_student, + us.is_primary + FROM user_student us + INNER JOIN student s ON s.id_student = us.id_student + WHERE us.id_user = p_id_user + AND s.id_program = p_id_program; +END;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_Insert.sql b/db/Objects/procedures/sp_Student_Insert.sql index e4bd77c..2e06e6b 100644 --- a/db/Objects/procedures/sp_Student_Insert.sql +++ b/db/Objects/procedures/sp_Student_Insert.sql @@ -13,7 +13,6 @@ BEGIN ( id_student, id_program, - id_user, identifier, program_year, enrollment_date, @@ -24,17 +23,17 @@ BEGIN ( p_id_student, p_id_program, - p_id_user, p_identifier, p_program_year, p_enrollment_date, p_expected_grad, UTC_TIMESTAMP() ); + INSERT INTO user_student (id_user_student, id_user, id_student, is_primary) + VALUES (UUID(), p_id_user, p_id_student, 1); SELECT id_student, id_program, - id_user, identifier, program_year, enrollment_date, diff --git a/db/Objects/tables/student.sql b/db/Objects/tables/student.sql index d5f51e6..44c4d76 100644 --- a/db/Objects/tables/student.sql +++ b/db/Objects/tables/student.sql @@ -1,7 +1,6 @@ CREATE TABLE `student` ( `id_student` char(36) NOT NULL, `id_program` char(36) DEFAULT NULL, - `id_user` char(36) DEFAULT NULL, `identifier` varchar(50) DEFAULT NULL, `program_year` int DEFAULT NULL, `enrollment_date` date DEFAULT NULL, @@ -9,7 +8,5 @@ CREATE TABLE `student` ( `created_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id_student`), KEY `student_ibfk_1` (`id_program`), - KEY `student_ibfk_2` (`id_user`), - CONSTRAINT `student_ibfk_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`), - CONSTRAINT `student_ibfk_2` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`) + CONSTRAINT `student_ibfk_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/ui/winstudentgoaltracker/src/app/app.config.ts b/ui/winstudentgoaltracker/src/app/app.config.ts index b7cb093..9673019 100644 --- a/ui/winstudentgoaltracker/src/app/app.config.ts +++ b/ui/winstudentgoaltracker/src/app/app.config.ts @@ -3,7 +3,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import { authInterceptor } from './interceptors/auth.interceptor'; +import { authInterceptor } from './shared/interceptors/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ diff --git a/ui/winstudentgoaltracker/src/app/app.routes.ts b/ui/winstudentgoaltracker/src/app/app.routes.ts index 6725dc3..f955d85 100644 --- a/ui/winstudentgoaltracker/src/app/app.routes.ts +++ b/ui/winstudentgoaltracker/src/app/app.routes.ts @@ -1,7 +1,17 @@ +import { inject } from '@angular/core'; import { Routes } from '@angular/router'; -import { Login } from './pages/login/login'; +import { Login } from './shared/pages/login/login'; +import { PlatformService } from './shared/services/platform.service'; export const routes: Routes = [ - { path: '', component: Login }, { path: 'login', component: Login }, + { + path: '', + canMatch: [() => inject(PlatformService).formFactor() === 'mobile'], + loadChildren: () => import('./mobile/mobile.routes'), + }, + { + path: '', + loadChildren: () => import('./desktop/desktop.routes'), + }, ]; diff --git a/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts new file mode 100644 index 0000000..9e556cb --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/desktop.routes.ts @@ -0,0 +1,14 @@ +import { Routes } from '@angular/router'; +import { Home } from './pages/home/home'; +import { StudentCardList } from './pages/components/student-card-list/student-card-list'; + +export default [ + { + path: '', + component: Home, + children: [ + { path: '', redirectTo: 'students', pathMatch: 'full' }, + { path: 'students', component: StudentCardList }, + ], + }, +] satisfies Routes; diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.html b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.html new file mode 100644 index 0000000..79a45e3 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.html @@ -0,0 +1,14 @@ +
+ +
+ +@if (displayMode() === 'card') { +
+ @for (student of students(); track student.studentId) { + + } +
+} @else { + +

List view coming soon.

+} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.scss new file mode 100644 index 0000000..559d66b --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.scss @@ -0,0 +1,41 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + 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; +} + +.card-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + overflow-y: auto; + flex: 1; +} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.ts new file mode 100644 index 0000000..15c7b4c --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card-list/student-card-list.ts @@ -0,0 +1,48 @@ +import { Component, inject, signal } from '@angular/core'; +import { StudentCard } from '../student-card/student-card'; +import { StudentService } from '../../../../shared/services/student.service'; +import { StudentCardDto } from '../../../../shared/models/dto/student-card.dto'; + +export type DisplayMode = 'card' | 'list'; + +@Component({ + selector: 'app-student-card-list', + imports: [StudentCard], + templateUrl: './student-card-list.html', + styleUrl: './student-card-list.scss', +}) +export class StudentCardList { + + // ************************** Constructor ************************** + + constructor() { + this.loadStudents(); + } + + // ************************** Declarations ************************* + + private readonly studentService = inject(StudentService); + protected readonly students = signal([]); + protected readonly displayMode = signal('card'); + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + onAddStudent() { + // TODO: navigate to add-student form + } + + // ********************** Support Procedures *********************** + + // ***************************************************************** + // Loads students from the service and populates the students signal. + // ***************************************************************** + private loadStudents() { + this.studentService.getStudentCards().subscribe(data => { + this.students.set(data); + }); + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.html b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.html new file mode 100644 index 0000000..d427c77 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.html @@ -0,0 +1,19 @@ +
+

🎓 {{ student().identifier }}

+ +
+ Age: {{ student().age }} + + @if (student().lastEntryDate) { + Last entry: {{ student().lastEntryDate | date:'M/d/yy' }} + } @else { + No entries yet + } + +
+ +
+ Goals: {{ student().goalCount }} + Events: {{ student().progressEventCount }} +
+
\ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.scss new file mode 100644 index 0000000..d5af2c1 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.scss @@ -0,0 +1,42 @@ +:host { + display: block; + width: 280px; +} + +.card { + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + padding: 1.5rem; +} + +.identifier { + margin: 0 0 0.75rem; + font-size: 1.5rem; + text-align: center; +} + +.meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + font-size: 0.875rem; + color: #666; +} + +.badge { + padding: 0.25rem 0.5rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.8125rem; + color: #333; +} + +.stats { + display: flex; + justify-content: space-around; + font-size: 0.875rem; + font-weight: 500; + color: #333; +} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.ts new file mode 100644 index 0000000..061f528 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/components/student-card/student-card.ts @@ -0,0 +1,26 @@ +import { Component, input } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { StudentCardDto } from '../../../../shared/models/dto/student-card.dto'; + +@Component({ + selector: 'app-student-card', + imports: [DatePipe], + templateUrl: './student-card.html', + styleUrl: './student-card.scss', +}) +export class StudentCard { + + // ************************** Constructor ************************** + + // ************************** Declarations ************************* + + readonly student = input.required(); + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + // ********************** Support Procedures *********************** +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html new file mode 100644 index 0000000..3ef3bc5 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html @@ -0,0 +1,24 @@ +
+ +
+ + WinStudentGoalTracker +
+ + +
+ + +
+ +
+
+ + +
+ © 2026 WinStudentGoalTracker +
+
\ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss new file mode 100644 index 0000000..7a7f232 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss @@ -0,0 +1,110 @@ +:host { + display: block; + height: 100%; +} + +.shell { + display: flex; + flex-direction: column; + height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* Header */ +.header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1rem; + height: 48px; + background: #fff; + border-bottom: 1px solid #ddd; + flex-shrink: 0; +} + +.menu-toggle { + background: none; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + color: #333; +} + +.menu-toggle:hover { + background: #f5f5f5; +} + +.header-title { + font-weight: 600; + font-size: 1rem; + color: #333; +} + +/* Body: Sidebar + Content */ +.body { + display: flex; + flex: 1; + overflow: hidden; +} + +.sidebar { + width: 0; + background: #fff; + border-right: 1px solid #ddd; + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.2s ease; + flex-shrink: 0; +} + +.sidebar.expanded { + width: 220px; +} + +.nav-item { + display: block; + padding: 0.75rem 1rem; + color: #333; + text-decoration: none; + white-space: nowrap; + font-size: 0.875rem; +} + +.nav-item:hover { + background: #f5f5f5; +} + +.nav-item.sub { + padding-left: 2rem; + font-size: 0.8125rem; +} + +.nav-item.active { + font-weight: 600; + color: #4f46e5; + background: #eef2ff; +} + +/* Main content */ +.content { + flex: 1; + overflow-y: auto; + padding: 1.5rem; + background: #f5f5f5; +} + +/* Footer */ +.footer { + display: flex; + align-items: center; + padding: 0 1rem; + height: 32px; + background: #fff; + border-top: 1px solid #ddd; + color: #666; + font-size: 0.75rem; + flex-shrink: 0; +} \ No newline at end of file diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts new file mode 100644 index 0000000..3c3278c --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts @@ -0,0 +1,29 @@ +import { Component, signal } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-home', + imports: [RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './home.html', + styleUrl: './home.scss', +}) +export class Home { + + // ************************** Constructor ************************** + + // ************************** Declarations ************************* + + protected readonly sidebarExpanded = signal(false); + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + onToggleSidebar() { + this.sidebarExpanded.update(v => !v); + } + + // ********************** Support Procedures *********************** +} diff --git a/ui/winstudentgoaltracker/src/app/mobile/mobile.routes.ts b/ui/winstudentgoaltracker/src/app/mobile/mobile.routes.ts new file mode 100644 index 0000000..168c5c2 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/mobile/mobile.routes.ts @@ -0,0 +1,6 @@ +import { Routes } from '@angular/router'; +import { Home } from './pages/home/home'; + +export default [ + { path: '', component: Home }, +] satisfies Routes; diff --git a/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.html b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.html new file mode 100644 index 0000000..a72ce19 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.html @@ -0,0 +1 @@ +

Mobile home works!

diff --git a/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.scss b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.ts b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.ts new file mode 100644 index 0000000..fe83818 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/mobile/pages/home/home.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-home', + imports: [], + templateUrl: './home.html', + styleUrl: './home.scss', +}) +export class Home { + +} diff --git a/ui/winstudentgoaltracker/src/app/pages/login/login.ts b/ui/winstudentgoaltracker/src/app/pages/login/login.ts deleted file mode 100644 index 02d31fe..0000000 --- a/ui/winstudentgoaltracker/src/app/pages/login/login.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { Component, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { Auth } from '../../services/auth'; - -@Component({ - selector: 'app-login', - imports: [FormsModule], - template: ` - - @if (!auth.isAuthenticated() && !auth.isSelectingProgram()) { -
-

Login

- - @if (error()) { -

{{ error() }}

- } - -
- - - - - -
-
- } - - - @if (auth.isSelectingProgram()) { -
-

Select a Program

-

Choose which program to log into.

- - @if (error()) { -

{{ error() }}

- } - -
- @for (program of auth.programs(); track program.programId) { - - } -
- - -
- } - - - @if (auth.isAuthenticated()) { -
-

Authenticated

- - @if (auth.user(); as user) { -
-
User ID
-
{{ user.userId }}
- -
Email
-
{{ user.email }}
- -
Program ID
-
{{ user.programId }}
- -
Role
-
{{ user.role }}
-
- } - - -
- } - `, - styles: ` - :host { - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f5f5f5; - padding: 1rem; - } - - .card { - background: #fff; - border-radius: 8px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); - padding: 2rem; - width: 100%; - max-width: 420px; - } - - h2 { - margin: 0 0 0.5rem; - font-size: 1.5rem; - } - - .subtitle { - margin: 0 0 1rem; - color: #666; - font-size: 0.875rem; - } - - form { - display: flex; - flex-direction: column; - gap: 1rem; - margin-top: 1rem; - } - - label { - display: flex; - flex-direction: column; - gap: 0.25rem; - font-size: 0.875rem; - font-weight: 500; - color: #333; - } - - input { - padding: 0.625rem 0.75rem; - border: 1px solid #ddd; - border-radius: 6px; - font-size: 0.9375rem; - outline: none; - transition: border-color 0.15s; - } - - input:focus { - border-color: #4f46e5; - } - - button[type='submit'], - button:not(.program-button):not(.link-button) { - padding: 0.625rem 1rem; - background: #4f46e5; - color: #fff; - border: none; - border-radius: 6px; - font-size: 0.9375rem; - font-weight: 500; - cursor: pointer; - transition: background 0.15s; - margin-top: 0.5rem; - } - - button[type='submit']:hover, - button:not(.program-button):not(.link-button):hover { - background: #4338ca; - } - - button:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - .error { - background: #fef2f2; - color: #dc2626; - padding: 0.625rem 0.75rem; - border-radius: 6px; - font-size: 0.875rem; - margin: 0.5rem 0; - } - - .program-list { - display: flex; - flex-direction: column; - gap: 0.5rem; - } - - .program-button { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.125rem; - padding: 0.75rem 1rem; - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 6px; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; - text-align: left; - width: 100%; - } - - .program-button:hover { - background: #eef2ff; - border-color: #4f46e5; - } - - .program-name { - font-weight: 500; - font-size: 0.9375rem; - color: #111; - } - - .program-meta { - font-size: 0.8125rem; - color: #666; - } - - .link-button { - background: none; - border: none; - color: #4f46e5; - cursor: pointer; - font-size: 0.875rem; - padding: 0.5rem 0; - margin-top: 0.75rem; - } - - .link-button:hover { - text-decoration: underline; - } - - dl { - display: grid; - grid-template-columns: auto 1fr; - gap: 0.5rem 1rem; - margin: 1rem 0; - font-size: 0.9375rem; - } - - dt { - font-weight: 500; - color: #555; - } - - dd { - margin: 0; - color: #111; - word-break: break-all; - } - - .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.8125rem; - } - `, -}) -export class Login { - protected readonly auth = inject(Auth); - - protected email = ''; - protected password = ''; - protected readonly loading = signal(false); - protected readonly error = signal(null); - - onLogin() { - this.error.set(null); - this.loading.set(true); - - this.auth.login(this.email, this.password).subscribe({ - next: (res) => { - this.loading.set(false); - if (!res.success) { - this.error.set(res.message); - } - }, - error: () => { - this.loading.set(false); - this.error.set('Unable to reach the server.'); - }, - }); - } - - onSelectProgram(programId: string) { - this.error.set(null); - this.loading.set(true); - - this.auth.selectProgram(programId).subscribe({ - next: (res) => { - this.loading.set(false); - if (!res.success) { - this.error.set(res.message); - } - }, - error: () => { - this.loading.set(false); - this.error.set('Unable to reach the server.'); - }, - }); - } - - onLogout() { - this.auth.logout().subscribe(); - } - - onBackToLogin() { - this.error.set(null); - // Clear session state so we go back to the login form - this.auth.logout().subscribe(); - } -} diff --git a/ui/winstudentgoaltracker/src/app/interceptors/auth.interceptor.ts b/ui/winstudentgoaltracker/src/app/shared/interceptors/auth.interceptor.ts similarity index 100% rename from ui/winstudentgoaltracker/src/app/interceptors/auth.interceptor.ts rename to ui/winstudentgoaltracker/src/app/shared/interceptors/auth.interceptor.ts diff --git a/ui/winstudentgoaltracker/src/app/models/auth.models.ts b/ui/winstudentgoaltracker/src/app/shared/models/auth.models.ts similarity index 100% rename from ui/winstudentgoaltracker/src/app/models/auth.models.ts rename to ui/winstudentgoaltracker/src/app/shared/models/auth.models.ts diff --git a/ui/winstudentgoaltracker/src/app/shared/models/dto/student-card.dto.ts b/ui/winstudentgoaltracker/src/app/shared/models/dto/student-card.dto.ts new file mode 100644 index 0000000..5cec264 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/models/dto/student-card.dto.ts @@ -0,0 +1,8 @@ +export interface StudentCardDto { + studentId: string; + identifier: string; + age: number; + lastEntryDate: string | null; + goalCount: number; + progressEventCount: number; +} diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.css b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.css new file mode 100644 index 0000000..c3b3bfa --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.css @@ -0,0 +1,166 @@ +:host { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; + padding: 1rem; +} + +.card { + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + padding: 2rem; + width: 100%; + max-width: 420px; +} + +h2 { + margin: 0 0 0.5rem; + font-size: 1.5rem; +} + +.subtitle { + margin: 0 0 1rem; + color: #666; + font-size: 0.875rem; +} + +form { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + color: #333; +} + +input { + padding: 0.625rem 0.75rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.9375rem; + outline: none; + transition: border-color 0.15s; +} + +input:focus { + border-color: #4f46e5; +} + +button[type='submit'], +button:not(.program-button):not(.link-button) { + padding: 0.625rem 1rem; + background: #4f46e5; + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; + margin-top: 0.5rem; +} + +button[type='submit']:hover, +button:not(.program-button):not(.link-button):hover { + background: #4338ca; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.error { + background: #fef2f2; + color: #dc2626; + padding: 0.625rem 0.75rem; + border-radius: 6px; + font-size: 0.875rem; + margin: 0.5rem 0; +} + +.program-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.program-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; + padding: 0.75rem 1rem; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + text-align: left; + width: 100%; +} + +.program-button:hover { + background: #eef2ff; + border-color: #4f46e5; +} + +.program-name { + font-weight: 500; + font-size: 0.9375rem; + color: #111; +} + +.program-meta { + font-size: 0.8125rem; + color: #666; +} + +.link-button { + background: none; + border: none; + color: #4f46e5; + cursor: pointer; + font-size: 0.875rem; + padding: 0.5rem 0; + margin-top: 0.75rem; +} + +.link-button:hover { + text-decoration: underline; +} + +dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1rem; + margin: 1rem 0; + font-size: 0.9375rem; +} + +dt { + font-weight: 500; + color: #555; +} + +dd { + margin: 0; + color: #111; + word-break: break-all; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; +} diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.html b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.html new file mode 100644 index 0000000..a902090 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.html @@ -0,0 +1,78 @@ + +@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) { +
+

Login

+ + @if (error()) { +

{{ error() }}

+ } + +
+ + + + + +
+
+} + + +@if (auth.isSelectingProgram()) { +
+

Select a Program

+

Choose which program to log into.

+ + @if (error()) { +

{{ error() }}

+ } + +
+ @for (program of auth.programs(); track program.programId) { + + } +
+ + +
+} + + +@if (auth.isAuthenticated()) { +
+

Authenticated

+ + @if (auth.user(); as user) { +
+
User ID
+
{{ user.userId }}
+ +
Email
+
{{ user.email }}
+ +
Program ID
+
{{ user.programId }}
+ +
Role
+
{{ user.role }}
+
+ } + + +
+} diff --git a/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts new file mode 100644 index 0000000..abf66a4 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/pages/login/login.ts @@ -0,0 +1,82 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Auth } from '../../services/auth'; + +@Component({ + selector: 'app-login', + imports: [FormsModule], + templateUrl: './login.html', + styleUrl: './login.css', +}) +export class Login { + + // ************************** Constructor ************************** + + // ************************** Declarations ************************* + + private readonly router = inject(Router); + protected readonly auth = inject(Auth); + protected email = ''; + protected password = ''; + protected readonly loading = signal(false); + protected readonly error = signal(null); + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ************************ Event Handlers ************************* + + onLogin() { + this.error.set(null); + this.loading.set(true); + + this.auth.login(this.email, this.password).subscribe({ + next: (res) => { + this.loading.set(false); + if (!res.success) { + this.error.set(res.message); + } + }, + error: () => { + this.loading.set(false); + this.error.set('Unable to reach the server.'); + }, + }); + } + + onSelectProgram(programId: string) { + this.error.set(null); + this.loading.set(true); + + this.auth.selectProgram(programId).subscribe({ + next: (res) => { + this.loading.set(false); + if (!res.success) { + this.error.set(res.message); + } + }, + error: () => { + this.loading.set(false); + this.error.set('Unable to reach the server.'); + }, + }); + } + + onHome() { + this.router.navigateByUrl('/'); + } + + onLogout() { + this.auth.logout().subscribe(); + } + + onBackToLogin() { + this.error.set(null); + // Clear session state so we go back to the login form + this.auth.logout().subscribe(); + } + + // ********************** Support Procedures *********************** +} diff --git a/ui/winstudentgoaltracker/src/app/services/api.spec.ts b/ui/winstudentgoaltracker/src/app/shared/services/api.spec.ts similarity index 100% rename from ui/winstudentgoaltracker/src/app/services/api.spec.ts rename to ui/winstudentgoaltracker/src/app/shared/services/api.spec.ts diff --git a/ui/winstudentgoaltracker/src/app/services/api.ts b/ui/winstudentgoaltracker/src/app/shared/services/api.ts similarity index 96% rename from ui/winstudentgoaltracker/src/app/services/api.ts rename to ui/winstudentgoaltracker/src/app/shared/services/api.ts index 85a2189..6efa537 100644 --- a/ui/winstudentgoaltracker/src/app/services/api.ts +++ b/ui/winstudentgoaltracker/src/app/shared/services/api.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { environment } from '../../environments/environment'; +import { environment } from '../../../environments/environment'; import { LoginRequest, LoginResponse, diff --git a/ui/winstudentgoaltracker/src/app/services/auth.spec.ts b/ui/winstudentgoaltracker/src/app/shared/services/auth.spec.ts similarity index 100% rename from ui/winstudentgoaltracker/src/app/services/auth.spec.ts rename to ui/winstudentgoaltracker/src/app/shared/services/auth.spec.ts diff --git a/ui/winstudentgoaltracker/src/app/services/auth.ts b/ui/winstudentgoaltracker/src/app/shared/services/auth.ts similarity index 100% rename from ui/winstudentgoaltracker/src/app/services/auth.ts rename to ui/winstudentgoaltracker/src/app/shared/services/auth.ts diff --git a/ui/winstudentgoaltracker/src/app/shared/services/platform.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/platform.service.ts new file mode 100644 index 0000000..2492644 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/services/platform.service.ts @@ -0,0 +1,73 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { Router } from '@angular/router'; + +export type FormFactor = 'mobile' | 'desktop'; + +@Injectable({ + providedIn: 'root', +}) +export class PlatformService { + private readonly router = inject(Router); + + // ──── Raw Hardware Signals ──── + + private readonly isCoarsePointer = + typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; + + private readonly screenWidth = + typeof window !== 'undefined' ? window.innerWidth : 1920; + + private readonly screenHeight = + typeof window !== 'undefined' ? window.innerHeight : 1080; + + private readonly minDimension = Math.min(this.screenWidth, this.screenHeight); + private readonly maxDimension = Math.max(this.screenWidth, this.screenHeight); + + // ──── Override Layer ──── + + /** When non-null, overrides auto-detection. */ + private readonly formFactorOverride = signal(null); + + // ──── Public API ──── + + /** The resolved form factor — auto-detected or overridden. */ + readonly formFactor = computed(() => + this.formFactorOverride() ?? this.resolveFormFactor(), + ); + + /** True when the user has manually toggled away from auto-detection. */ + readonly isOverridden = computed(() => this.formFactorOverride() !== null); + + /** + * Switch to a specific form factor at runtime. + * Forces a route re-evaluation so the user immediately sees the other experience. + */ + switchTo(target: FormFactor): void { + this.formFactorOverride.set(target); + this.router.navigateByUrl(this.router.url); + } + + /** Clear the override and return to auto-detected form factor. */ + resetToAuto(): void { + this.formFactorOverride.set(null); + this.router.navigateByUrl(this.router.url); + } + + // ──── Device Classification Policy ──── + + private resolveFormFactor(): FormFactor { + // Non-touch devices are always desktop + if (!this.isCoarsePointer) return 'desktop'; + + // Large tablets (iPad Pro 12.9" portrait = 1024px) → desktop + if (this.minDimension >= 1024) return 'desktop'; + + // Medium tablets in landscape with sufficient width → desktop + if (this.maxDimension >= 1180 && this.screenWidth > this.screenHeight) { + return 'desktop'; + } + + // Phones and small/portrait tablets → mobile + return 'mobile'; + } +} diff --git a/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts new file mode 100644 index 0000000..304a1b7 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/services/student.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { StudentCardDto } from '../models/dto/student-card.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class StudentService { + + // ************************** Constructor ************************** + + // ************************** Declarations ************************* + + // ************************** Properties *************************** + + // ************************ Public Methods ************************* + + // ***************************************************************** + // Returns student card summaries. Currently returns dummy data + // until the API endpoint is available. + // ***************************************************************** + getStudentCards(): Observable { + return of([ + { + studentId: '1', + identifier: 'J.B', + age: 21, + lastEntryDate: '2026-02-21', + goalCount: 3, + progressEventCount: 5, + }, + { + studentId: '2', + identifier: 'M.K', + age: 19, + lastEntryDate: '2026-02-25', + goalCount: 4, + progressEventCount: 8, + }, + { + studentId: '3', + identifier: 'A.R', + age: 22, + lastEntryDate: null, + goalCount: 2, + progressEventCount: 0, + }, + ]); + } + + // ************************ Event Handlers ************************* + + // ********************** Support Procedures *********************** +} diff --git a/ui/winstudentgoaltracker/src/styles.scss b/ui/winstudentgoaltracker/src/styles.scss index 90d4ee0..65fcaae 100644 --- a/ui/winstudentgoaltracker/src/styles.scss +++ b/ui/winstudentgoaltracker/src/styles.scss @@ -1 +1,9 @@ /* You can add global styles to this file, and also import other style files */ + +html, +body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} \ No newline at end of file