mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 04:07:39 +00:00
Desktop UI updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,6 @@ BEGIN
|
||||
SELECT
|
||||
id_student,
|
||||
id_program,
|
||||
id_user,
|
||||
identifier,
|
||||
program_year,
|
||||
enrollment_date,
|
||||
|
||||
@@ -4,7 +4,6 @@ BEGIN
|
||||
SELECT
|
||||
id_student,
|
||||
id_program,
|
||||
id_user,
|
||||
identifier,
|
||||
program_year,
|
||||
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_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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 { inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
LoginRequest,
|
||||
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 */
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
Reference in New Issue
Block a user