mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 02:57:36 +00:00
Merge branch 'main' of https://github.com/opelly27/WinStudentGoalTracker
This commit is contained in:
@@ -3,3 +3,7 @@
|
|||||||
/prototype/RolesAssignments/.vs
|
/prototype/RolesAssignments/.vs
|
||||||
/prototype/RolesAssignments/bin
|
/prototype/RolesAssignments/bin
|
||||||
/prototype/RolesAssignments/obj
|
/prototype/RolesAssignments/obj
|
||||||
|
/.vs/WinStudentGoalTracker/CopilotIndices
|
||||||
|
/.vs/WinStudentGoalTracker/FileContentIndex
|
||||||
|
/.vs/WinStudentGoalTracker/DesignTimeBuild
|
||||||
|
/.vs/WinStudentGoalTracker/v17
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
DELIMITER ;;
|
DELIMITER ;;
|
||||||
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36))
|
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36))
|
||||||
BEGIN
|
BEGIN
|
||||||
|
DELETE FROM user_student
|
||||||
|
WHERE id_student = p_id_student;
|
||||||
DELETE FROM student
|
DELETE FROM student
|
||||||
WHERE id_student = p_id_student;
|
WHERE id_student = p_id_student;
|
||||||
SELECT ROW_COUNT() AS rows_affected;
|
SELECT ROW_COUNT() AS rows_affected;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ BEGIN
|
|||||||
SELECT
|
SELECT
|
||||||
id_student,
|
id_student,
|
||||||
id_program,
|
id_program,
|
||||||
id_user,
|
|
||||||
identifier,
|
identifier,
|
||||||
program_year,
|
program_year,
|
||||||
enrollment_date,
|
enrollment_date,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ BEGIN
|
|||||||
SELECT
|
SELECT
|
||||||
id_student,
|
id_student,
|
||||||
id_program,
|
id_program,
|
||||||
id_user,
|
|
||||||
identifier,
|
identifier,
|
||||||
program_year,
|
program_year,
|
||||||
enrollment_date,
|
enrollment_date,
|
||||||
|
|||||||
@@ -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 ;
|
|
||||||
@@ -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 ;
|
|
||||||
@@ -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 ;
|
||||||
@@ -13,7 +13,6 @@ BEGIN
|
|||||||
(
|
(
|
||||||
id_student,
|
id_student,
|
||||||
id_program,
|
id_program,
|
||||||
id_user,
|
|
||||||
identifier,
|
identifier,
|
||||||
program_year,
|
program_year,
|
||||||
enrollment_date,
|
enrollment_date,
|
||||||
@@ -24,17 +23,17 @@ BEGIN
|
|||||||
(
|
(
|
||||||
p_id_student,
|
p_id_student,
|
||||||
p_id_program,
|
p_id_program,
|
||||||
p_id_user,
|
|
||||||
p_identifier,
|
p_identifier,
|
||||||
p_program_year,
|
p_program_year,
|
||||||
p_enrollment_date,
|
p_enrollment_date,
|
||||||
p_expected_grad,
|
p_expected_grad,
|
||||||
UTC_TIMESTAMP()
|
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
|
SELECT
|
||||||
id_student,
|
id_student,
|
||||||
id_program,
|
id_program,
|
||||||
id_user,
|
|
||||||
identifier,
|
identifier,
|
||||||
program_year,
|
program_year,
|
||||||
enrollment_date,
|
enrollment_date,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
CREATE TABLE `student` (
|
CREATE TABLE `student` (
|
||||||
`id_student` char(36) NOT NULL,
|
`id_student` char(36) NOT NULL,
|
||||||
`id_program` char(36) DEFAULT NULL,
|
`id_program` char(36) DEFAULT NULL,
|
||||||
`id_user` char(36) DEFAULT NULL,
|
|
||||||
`identifier` varchar(50) DEFAULT NULL,
|
`identifier` varchar(50) DEFAULT NULL,
|
||||||
`program_year` int DEFAULT NULL,
|
`program_year` int DEFAULT NULL,
|
||||||
`enrollment_date` date DEFAULT NULL,
|
`enrollment_date` date DEFAULT NULL,
|
||||||
@@ -9,7 +8,5 @@ CREATE TABLE `student` (
|
|||||||
`created_at` timestamp NULL DEFAULT NULL,
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
PRIMARY KEY (`id_student`),
|
PRIMARY KEY (`id_student`),
|
||||||
KEY `student_ibfk_1` (`id_program`),
|
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_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`),
|
|
||||||
CONSTRAINT `student_ibfk_2` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
import { authInterceptor } from './shared/interceptors/auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
import { Routes } from '@angular/router';
|
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 = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: Login },
|
|
||||||
{ path: 'login', component: Login },
|
{ path: 'login', component: Login },
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
|
||||||
|
loadChildren: () => import('./mobile/mobile.routes'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadChildren: () => import('./desktop/desktop.routes'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<div class="toolbar">
|
||||||
|
<button class="toolbar-btn" (click)="onAddStudent()">+ Add a Student</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (displayMode() === 'card') {
|
||||||
|
<div class="card-grid">
|
||||||
|
@for (student of students(); track student.studentId) {
|
||||||
|
<app-student-card [student]="student" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- List mode — to be implemented -->
|
||||||
|
<p>List view coming soon.</p>
|
||||||
|
}
|
||||||
+41
@@ -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;
|
||||||
|
}
|
||||||
+48
@@ -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<StudentCardDto[]>([]);
|
||||||
|
protected readonly displayMode = signal<DisplayMode>('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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="card">
|
||||||
|
<h2 class="identifier">🎓 {{ student().identifier }}</h2>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<span class="badge">Age: {{ student().age }}</span>
|
||||||
|
<span class="last-entry">
|
||||||
|
@if (student().lastEntryDate) {
|
||||||
|
Last entry: {{ student().lastEntryDate | date:'M/d/yy' }}
|
||||||
|
} @else {
|
||||||
|
No entries yet
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<span>Goals: {{ student().goalCount }}</span>
|
||||||
|
<span>Events: {{ student().progressEventCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+42
@@ -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;
|
||||||
|
}
|
||||||
+26
@@ -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<StudentCardDto>();
|
||||||
|
|
||||||
|
// ************************** Properties ***************************
|
||||||
|
|
||||||
|
// ************************ Public Methods *************************
|
||||||
|
|
||||||
|
// ************************ Event Handlers *************************
|
||||||
|
|
||||||
|
// ********************** Support Procedures ***********************
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<div class="shell">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<button class="menu-toggle" (click)="onToggleSidebar()">☰</button>
|
||||||
|
<span class="header-title">WinStudentGoalTracker</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Body: Sidebar + Main -->
|
||||||
|
<div class="body">
|
||||||
|
<nav class="sidebar" [class.expanded]="sidebarExpanded()">
|
||||||
|
<a class="nav-item" routerLink="/students">Home</a>
|
||||||
|
<a class="nav-item sub" routerLink="/students" routerLinkActive="active">My Students</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<span>© 2026 WinStudentGoalTracker</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 ***********************
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { Home } from './pages/home/home';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ path: '', component: Home },
|
||||||
|
] satisfies Routes;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>Mobile home works!</p>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './home.html',
|
||||||
|
styleUrl: './home.scss',
|
||||||
|
})
|
||||||
|
export class Home {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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: `
|
|
||||||
<!-- Phase 1: Email & Password -->
|
|
||||||
@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
|
|
||||||
<div class="card">
|
|
||||||
<h2>Login</h2>
|
|
||||||
|
|
||||||
@if (error()) {
|
|
||||||
<p class="error">{{ error() }}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<form (ngSubmit)="onLogin()">
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" [(ngModel)]="email" name="email" required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" [(ngModel)]="password" name="password" required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit" [disabled]="loading()">
|
|
||||||
{{ loading() ? 'Signing in...' : 'Sign in' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Phase 2: Program Selection -->
|
|
||||||
@if (auth.isSelectingProgram()) {
|
|
||||||
<div class="card">
|
|
||||||
<h2>Select a Program</h2>
|
|
||||||
<p class="subtitle">Choose which program to log into.</p>
|
|
||||||
|
|
||||||
@if (error()) {
|
|
||||||
<p class="error">{{ error() }}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="program-list">
|
|
||||||
@for (program of auth.programs(); track program.programId) {
|
|
||||||
<button
|
|
||||||
class="program-button"
|
|
||||||
[disabled]="loading()"
|
|
||||||
(click)="onSelectProgram(program.programId)"
|
|
||||||
>
|
|
||||||
<span class="program-name">{{ program.programName }}</span>
|
|
||||||
<span class="program-meta">{{ program.roleDisplayName }}{{ program.isPrimary ? ' (Primary)' : '' }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="link-button" (click)="onBackToLogin()">Back to login</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Authenticated: User Info -->
|
|
||||||
@if (auth.isAuthenticated()) {
|
|
||||||
<div class="card">
|
|
||||||
<h2>Authenticated</h2>
|
|
||||||
|
|
||||||
@if (auth.user(); as user) {
|
|
||||||
<dl>
|
|
||||||
<dt>User ID</dt>
|
|
||||||
<dd class="mono">{{ user.userId }}</dd>
|
|
||||||
|
|
||||||
<dt>Email</dt>
|
|
||||||
<dd>{{ user.email }}</dd>
|
|
||||||
|
|
||||||
<dt>Program ID</dt>
|
|
||||||
<dd class="mono">{{ user.programId }}</dd>
|
|
||||||
|
|
||||||
<dt>Role</dt>
|
|
||||||
<dd>{{ user.role }}</dd>
|
|
||||||
</dl>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button (click)="onLogout()">Logout</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
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<string | null>(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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface StudentCardDto {
|
||||||
|
studentId: string;
|
||||||
|
identifier: string;
|
||||||
|
age: number;
|
||||||
|
lastEntryDate: string | null;
|
||||||
|
goalCount: number;
|
||||||
|
progressEventCount: number;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!-- Phase 1: Email & Password -->
|
||||||
|
@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
|
||||||
|
<div class="card">
|
||||||
|
<h2>Login</h2>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<p class="error">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form (ngSubmit)="onLogin()">
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" [(ngModel)]="email" name="email" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" [(ngModel)]="password" name="password" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" [disabled]="loading()">
|
||||||
|
{{ loading() ? 'Signing in...' : 'Sign in' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Phase 2: Program Selection -->
|
||||||
|
@if (auth.isSelectingProgram()) {
|
||||||
|
<div class="card">
|
||||||
|
<h2>Select a Program</h2>
|
||||||
|
<p class="subtitle">Choose which program to log into.</p>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<p class="error">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="program-list">
|
||||||
|
@for (program of auth.programs(); track program.programId) {
|
||||||
|
<button
|
||||||
|
class="program-button"
|
||||||
|
[disabled]="loading()"
|
||||||
|
(click)="onSelectProgram(program.programId)"
|
||||||
|
>
|
||||||
|
<span class="program-name">{{ program.programName }}</span>
|
||||||
|
<span class="program-meta">{{ program.roleDisplayName }}{{ program.isPrimary ? ' (Primary)' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="link-button" (click)="onBackToLogin()">Back to login</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Authenticated: User Info -->
|
||||||
|
@if (auth.isAuthenticated()) {
|
||||||
|
<div class="card">
|
||||||
|
<h2>Authenticated</h2>
|
||||||
|
|
||||||
|
@if (auth.user(); as user) {
|
||||||
|
<dl>
|
||||||
|
<dt>User ID</dt>
|
||||||
|
<dd class="mono">{{ user.userId }}</dd>
|
||||||
|
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{{ user.email }}</dd>
|
||||||
|
|
||||||
|
<dt>Program ID</dt>
|
||||||
|
<dd class="mono">{{ user.programId }}</dd>
|
||||||
|
|
||||||
|
<dt>Role</dt>
|
||||||
|
<dd>{{ user.role }}</dd>
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
<button (click)="onHome()">Continue</button>
|
||||||
|
<button (click)="onLogout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 ***********************
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import {
|
import {
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -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<FormFactor | null>(null);
|
||||||
|
|
||||||
|
// ──── Public API ────
|
||||||
|
|
||||||
|
/** The resolved form factor — auto-detected or overridden. */
|
||||||
|
readonly formFactor = computed<FormFactor>(() =>
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<StudentCardDto[]> {
|
||||||
|
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 ***********************
|
||||||
|
}
|
||||||
@@ -1 +1,9 @@
|
|||||||
/* You can add global styles to this file, and also import other style files */
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user