update
This commit is contained in:
parent
6166350e56
commit
986d34ab12
69
.github/instructions/angular.instructions.md
vendored
Normal file
69
.github/instructions/angular.instructions.md
vendored
Normal file
@ -0,0 +1,69 @@
|
||||
---
|
||||
applyTo: "**"
|
||||
---
|
||||
|
||||
You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices. You use the features and best practices according to the latest Angular version. You write code that is easy to read, understand and maintain. You avoid unnecessary complexity and strive for simplicity in your code. You use the latest features of TypeScript and Angular to write clean, efficient and scalable code.
|
||||
|
||||
## TypeScript Best Practices
|
||||
|
||||
- Use strict type checking
|
||||
- Prefer type interfaces when the type is obvious
|
||||
- Avoid the `any` type; use `unknown` when type is uncertain
|
||||
- Use `readonly` #<fieldName> for private class fields
|
||||
- Use `const` for constants and `let` for variables that may change
|
||||
- Use the following syntax for `enums`:
|
||||
|
||||
```typescript
|
||||
export enum WeekDayEnum = <const>{
|
||||
MONDAY: 'Monday',
|
||||
TUESDAY: 'Tuesday',
|
||||
WEDNESDAY: 'Wednesday',
|
||||
THURSDAY: 'Thursday',
|
||||
FRIDAY: 'Friday',
|
||||
SATURDAY: 'Saturday',
|
||||
SUNDAY: 'Sunday'
|
||||
};
|
||||
|
||||
export type WeekDay = (typeof WeekDayEnum)[keyof typeof WeekDayEnum];
|
||||
```
|
||||
|
||||
- Add all types and interfaces to a `<entity>.types.ts` file
|
||||
|
||||
## Angular Best Practices
|
||||
|
||||
- Always use standalone components over NgModules
|
||||
- Don't use explicit `standalone: true` (it is implied by default)
|
||||
- Use signals for state management
|
||||
- Implement lazy loading for feature routes
|
||||
- Use `NgOptimizedImage` for all static images
|
||||
|
||||
## Components
|
||||
|
||||
- Keep components small and focused on a single responsibility
|
||||
- Use `input()` and `output()` functions instead of decorators
|
||||
- Use `computed()` for derived state
|
||||
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
|
||||
- Prefer inline templates for small components
|
||||
- Prefer Reactive forms instead of Template-driven ones
|
||||
- Do NOT use `ngClass`, use `class` bindings instead
|
||||
- Do NOT use `ngStyle`, use `style` bindings instead
|
||||
|
||||
## State Management
|
||||
|
||||
- Use signals for local component state
|
||||
- Use `computed()` for derived state
|
||||
- Keep state transformations pure and predictable
|
||||
|
||||
## Templates
|
||||
|
||||
- Keep templates simple and avoid complex logic
|
||||
- Use Signals for your template bindings whenever possible
|
||||
- Use untagged template literals in the template instead of concatenation
|
||||
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, and `*ngSwitch`
|
||||
- Use the async pipe to handle Observables
|
||||
|
||||
## Services
|
||||
|
||||
- Design services around a single responsibility
|
||||
- Use the `providedIn: 'root'` option for singleton services
|
||||
- Use the `inject()` function instead of constructor injection
|
||||
@ -9,7 +9,7 @@ export type Task = {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
user_id: number;
|
||||
userId: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
@ -21,8 +21,8 @@ export type TaskResponse = {
|
||||
export type Comment = {
|
||||
id: number;
|
||||
content: string;
|
||||
task_id: number;
|
||||
user_id: number;
|
||||
taskId: number;
|
||||
userId: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-navbar',
|
||||
imports: [MatToolbarModule, MatIcon],
|
||||
imports: [MatToolbarModule, MatIcon, MatButtonModule, RouterModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<mat-toolbar color="primary" class="navbar">
|
||||
<span class="logo">
|
||||
@ -36,17 +39,6 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
a[mat-button] {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
margin-left: 1rem;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
a[mat-button]:hover, a[mat-button].active-link {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './lib/task-list.component';
|
||||
export * from './lib/task-form.component';
|
||||
export * from './lib/add-task-dialog.component';
|
||||
|
||||
@ -1,39 +1,195 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog';
|
||||
import { TaskFormComponent } from './task-form.component';
|
||||
import {
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TasksService, User, UsersService } from '@org/shared/api';
|
||||
import { ToastService } from '@org/shared/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-add-task-dialog',
|
||||
standalone: true,
|
||||
template: `
|
||||
<h2 mat-dialog-title>Add New Task</h2>
|
||||
<mat-dialog-content>
|
||||
<lib-task-form (taskAdded)="onTaskAdded()"></lib-task-form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [`
|
||||
mat-dialog-content {
|
||||
min-width: 320px;
|
||||
<h2 mat-dialog-title class="dialog-title">Add New Task</h2>
|
||||
<mat-dialog-content class="dialog-content">
|
||||
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Assigned to</mat-label>
|
||||
<mat-select formControlName="userId" required>
|
||||
@for (user of users(); track user.id) {
|
||||
<mat-option [value]="user.id">{{ user.name }}</mat-option>
|
||||
}
|
||||
`],
|
||||
</mat-select>
|
||||
@if (taskForm.get('userId')?.hasError('required') &&
|
||||
taskForm.get('userId')?.touched) {
|
||||
<mat-error>Please select a user</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Title</mat-label>
|
||||
<input matInput formControlName="title" placeholder="Task title" />
|
||||
@if (taskForm.get('title')?.hasError('required') &&
|
||||
taskForm.get('title')?.touched) {
|
||||
<mat-error>Title is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="description"
|
||||
placeholder="Task description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-dialog-actions align="end" class="dialog-actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="taskForm.invalid"
|
||||
>
|
||||
<mat-icon>add_task</mat-icon>
|
||||
<span class="button-text">Add Task</span>
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
color: #1976d2;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.dialog-content {
|
||||
min-width: 400px;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin: 1.2rem 0;
|
||||
}
|
||||
.dialog-actions {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
button[mat-raised-button] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
padding: 0.75rem 1rem;
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
button[mat-raised-button] mat-icon {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
button[mat-raised-button] .button-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.dialog-content {
|
||||
min-width: 90vw;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
TaskFormComponent,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AddTaskDialogComponent {
|
||||
readonly dialogRef = inject(MatDialogRef<AddTaskDialogComponent>);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly tasksService = inject(TasksService);
|
||||
private readonly usersService = inject(UsersService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
onTaskAdded() {
|
||||
readonly users = signal<User[]>([]);
|
||||
|
||||
taskForm: FormGroup = this.fb.group({
|
||||
userId: ['', Validators.required],
|
||||
title: ['', Validators.required],
|
||||
description: [''],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
private loadUsers() {
|
||||
this.usersService.getUsers().subscribe({
|
||||
next: (users) => {
|
||||
this.users.set(users);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading users:', error);
|
||||
this.toastService.show('Failed to load users!', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.taskForm.invalid) return;
|
||||
|
||||
const task = {
|
||||
title: this.taskForm.value.title,
|
||||
description: this.taskForm.value.description,
|
||||
userId: parseInt(this.taskForm.value.userId),
|
||||
};
|
||||
|
||||
this.tasksService.createTask(task).subscribe({
|
||||
next: () => {
|
||||
this.taskForm.reset();
|
||||
this.toastService.show('Task created successfully!');
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error creating task:', error);
|
||||
this.toastService.show('Failed to create task!', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Output, inject, input } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
@ -9,25 +15,27 @@ import {
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TasksService } from '@org/shared/api';
|
||||
import { ToastService } from '@org/shared/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-task-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<mat-card>
|
||||
<mat-card class="task-form-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Add New Task</mat-card-title>
|
||||
<mat-card-title class="form-title">Add New Task</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
|
||||
@ -46,41 +54,79 @@ import { ToastService } from '@org/shared/toast';
|
||||
matInput
|
||||
formControlName="description"
|
||||
placeholder="Task description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="taskForm.invalid"
|
||||
>
|
||||
Add Task
|
||||
<mat-icon>add_task</mat-icon>
|
||||
<span class="button-text">Add Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
.task-form-card {
|
||||
margin: 2rem 0;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
.form-title {
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin: 1.2rem 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
button[mat-raised-button] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
padding: 0.75rem 1rem;
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
button[mat-raised-button] mat-icon {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
button[mat-raised-button] .button-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class TaskFormComponent {
|
||||
userId = input<number>(0);
|
||||
@Output() taskAdded = new EventEmitter<void>();
|
||||
readonly userId = input<number>(0);
|
||||
readonly taskAdded = output<void>();
|
||||
|
||||
private fb = inject(FormBuilder);
|
||||
private tasksService = inject(TasksService);
|
||||
private toastService = inject(ToastService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly tasksService = inject(TasksService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
taskForm: FormGroup = this.fb.group({
|
||||
title: ['', Validators.required],
|
||||
@ -93,7 +139,7 @@ export class TaskFormComponent {
|
||||
const task = {
|
||||
title: this.taskForm.value.title,
|
||||
description: this.taskForm.value.description,
|
||||
user_id: this.userId(),
|
||||
userId: this.userId(),
|
||||
};
|
||||
|
||||
this.tasksService.createTask(task).subscribe({
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { AddTaskDialogComponent } from './add-task-dialog.component';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
@ -11,6 +14,7 @@ import {
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@ -31,10 +35,10 @@ import {
|
||||
} from '@org/shared/api';
|
||||
import { ToastService } from '@org/shared/toast';
|
||||
import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
import { AddTaskDialogComponent } from './add-task-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-task-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
@ -52,17 +56,18 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
MatBadgeModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<mat-toolbar color="primary" class="page-header">
|
||||
<span>Task Management</span>
|
||||
<span>Tasks</span>
|
||||
<span class="spacer"></span>
|
||||
<button mat-icon-button matTooltip="Refresh data" (click)="loadData()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
<button class="fab" mat-fab color="accent" (click)="openAddTaskDialog()">
|
||||
<button class="fab" matFab color="accent" (click)="openAddTaskDialog()">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
|
||||
@ -86,6 +91,14 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
<div class="task-title-container">
|
||||
<mat-icon>assignment</mat-icon>
|
||||
<span>{{ task.tasks.title }}</span>
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
<div class="task-info">
|
||||
<div class="task-info-row">
|
||||
<span class="task-author"
|
||||
>By {{ getUserName(task.tasks.userId) }}</span
|
||||
>
|
||||
@if (getTaskComments(task.tasks.id).length) {
|
||||
<mat-icon
|
||||
class="comment-badge"
|
||||
@ -96,12 +109,6 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
</mat-icon>
|
||||
}
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
<div class="task-info">
|
||||
<span class="task-author"
|
||||
>By {{ getUserName(task.tasks.user_id) }}</span
|
||||
>
|
||||
<span class="task-timestamp">{{
|
||||
formatDate(task.tasks.timestamp)
|
||||
}}</span>
|
||||
@ -213,29 +220,20 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1100;
|
||||
}
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
.dialog-card {
|
||||
min-width: 320px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@ -256,11 +254,12 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 4rem 2rem;
|
||||
margin: 2rem 0;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@ -271,33 +270,86 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
color: #424242;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.task-container {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.task-panel {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.3s ease;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.task-panel:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.task-panel .mat-expansion-panel-header {
|
||||
height: auto;
|
||||
min-height: 80px;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.task-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.comment-badge {
|
||||
margin-left: 0.5rem;
|
||||
.task-title-container mat-icon {
|
||||
color: #1976d2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-title-container span {
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.task-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-author {
|
||||
font-weight: 500;
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
.task-timestamp {
|
||||
@ -306,18 +358,23 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
}
|
||||
|
||||
.task-details {
|
||||
padding: 1rem 0;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
white-space: pre-line;
|
||||
color: #424242;
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1976d2;
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
margin-top: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.comments-section h3 {
|
||||
@ -325,28 +382,42 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
.comments-section h3 mat-icon {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.comment-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
@ -357,38 +428,56 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
.comment-content {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
color: #424242;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
font-style: italic;
|
||||
color: #757575;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px dashed #e0e0e0;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.comment-form h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
color: #424242;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-form h4::before {
|
||||
content: '💬';
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-actions mat-form-field {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
@ -400,51 +489,54 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-container {
|
||||
padding: 0 0.5rem 5rem 0.5rem;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-actions mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class TaskListComponent implements OnInit {
|
||||
private tasksService = inject(TasksService);
|
||||
private usersService = inject(UsersService);
|
||||
private commentsService = inject(CommentsService);
|
||||
private fb = inject(FormBuilder);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
tasks = signal<TaskResponse[]>([]);
|
||||
users = signal<User[]>([]);
|
||||
comments = signal<CommentResponse[]>([]);
|
||||
loading = signal(true);
|
||||
addingComment = signal(false);
|
||||
|
||||
export class TaskListComponent {
|
||||
private readonly tasksService = inject(TasksService);
|
||||
private readonly usersService = inject(UsersService);
|
||||
private readonly commentsService = inject(CommentsService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
openAddTaskDialog() {
|
||||
const dialogRef = this.dialog.open(AddTaskDialogComponent, {
|
||||
width: '400px',
|
||||
enterAnimationDuration: '220ms',
|
||||
exitAnimationDuration: '180ms',
|
||||
autoFocus: true,
|
||||
restoreFocus: true,
|
||||
disableClose: false,
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
}
|
||||
readonly tasks = signal<TaskResponse[]>([]);
|
||||
readonly users = signal<User[]>([]);
|
||||
readonly comments = signal<CommentResponse[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly addingComment = signal(false);
|
||||
|
||||
commentForm: FormGroup = this.fb.group({
|
||||
readonly commentForm: FormGroup = this.fb.group({
|
||||
content: ['', Validators.required],
|
||||
userId: ['', Validators.required],
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
constructor() {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
@ -476,6 +568,22 @@ export class TaskListComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
openAddTaskDialog() {
|
||||
const dialogRef = this.dialog.open(AddTaskDialogComponent, {
|
||||
width: '400px',
|
||||
enterAnimationDuration: '220ms',
|
||||
exitAnimationDuration: '180ms',
|
||||
autoFocus: true,
|
||||
restoreFocus: true,
|
||||
disableClose: false,
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getUserName(userId: number): string {
|
||||
const user = this.users().find((u) => u.id === userId);
|
||||
return user ? user.name : 'Unknown User';
|
||||
@ -483,7 +591,7 @@ export class TaskListComponent implements OnInit {
|
||||
|
||||
getTaskComments(taskId: number): CommentResponse[] {
|
||||
return this.comments().filter(
|
||||
(comment) => comment.comments.task_id === taskId
|
||||
(comment) => comment.comments.taskId === taskId
|
||||
);
|
||||
}
|
||||
|
||||
@ -523,8 +631,8 @@ export class TaskListComponent implements OnInit {
|
||||
|
||||
const comment = {
|
||||
content: this.commentForm.value.content,
|
||||
task_id: taskId,
|
||||
user_id: parseInt(this.commentForm.value.userId),
|
||||
taskId: taskId,
|
||||
userId: parseInt(this.commentForm.value.userId),
|
||||
};
|
||||
|
||||
this.commentsService
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@ -17,7 +23,6 @@ import { TaskFormComponent } from '@org/web/tasks';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-user-details',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
@ -29,41 +34,77 @@ import { TaskFormComponent } from '@org/web/tasks';
|
||||
MatToolbarModule,
|
||||
TaskFormComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<mat-toolbar color="primary">User Details</mat-toolbar>
|
||||
<mat-toolbar color="primary">
|
||||
<span>User Details</span>
|
||||
</mat-toolbar>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
|
||||
<mat-progress-spinner
|
||||
mode="indeterminate"
|
||||
diameter="40"
|
||||
></mat-progress-spinner>
|
||||
<p>Loading user details...</p>
|
||||
</div>
|
||||
} @else if (user()) {
|
||||
<div class="user-container">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-icon aria-hidden="true">account_circle</mat-icon>
|
||||
<div>
|
||||
<mat-card-title>{{ user()?.name }}</mat-card-title>
|
||||
<mat-card-subtitle>{{ user()?.email }}</mat-card-subtitle>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p><strong>User ID:</strong> {{ user()?.id }}</p>
|
||||
<p><strong>Tasks:</strong> {{ userTasks().length }}</p>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button mat-button routerLink="/users">Back to Users</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
routerLink="/users"
|
||||
class="back-button"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
<span>Back to Users</span>
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
|
||||
<div class="tasks-section">
|
||||
<h2>User Tasks</h2>
|
||||
<h2>
|
||||
<mat-icon aria-hidden="true">assignment</mat-icon>
|
||||
<span>User Tasks</span>
|
||||
</h2>
|
||||
|
||||
@if (userTasks().length) {
|
||||
<mat-list>
|
||||
@for (task of userTasks(); track task.tasks.id) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>assignment</mat-icon>
|
||||
<h3 matListItemTitle>{{ task.tasks.title }}</h3>
|
||||
<p matListItemLine>
|
||||
<mat-icon
|
||||
matListItemIcon
|
||||
[style.color]="'#3f51b5'"
|
||||
aria-hidden="true"
|
||||
>task_alt</mat-icon
|
||||
>
|
||||
<div matListItemTitle>
|
||||
<h3 class="task-title">{{ task.tasks.title }}</h3>
|
||||
</div>
|
||||
<div matListItemLine class="task-description">
|
||||
{{ task.tasks.description || 'No description' }}
|
||||
</p>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
} @else {
|
||||
<div class="no-tasks">
|
||||
<mat-icon aria-hidden="true">assignment_late</mat-icon>
|
||||
<p>No tasks found for this user.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<lib-task-form
|
||||
@ -71,15 +112,42 @@ import { TaskFormComponent } from '@org/web/tasks';
|
||||
(taskAdded)="loadUserTasks()"
|
||||
></lib-task-form>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="error-container">
|
||||
<mat-icon
|
||||
style="font-size: 3rem; height: 3rem; width: 3rem; color: #f44336;"
|
||||
aria-hidden="true"
|
||||
>error_outline</mat-icon
|
||||
>
|
||||
<p>User not found</p>
|
||||
<button mat-button routerLink="/users">Back to Users</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
routerLink="/users"
|
||||
class="back-button"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
<span>Back to Users</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
:host {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
display: flex;
|
||||
@ -89,31 +157,238 @@ import { TaskFormComponent } from '@org/web/tasks';
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.loading-container,
|
||||
.error-container {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container p {
|
||||
color: #f44336;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
mat-toolbar {
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
mat-toolbar {
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-container {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.user-container {
|
||||
gap: 2rem;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card {
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
mat-card-header {
|
||||
margin-bottom: 1.25rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
mat-card-header {
|
||||
margin-bottom: 1.5rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
mat-card-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
mat-card-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tasks-section {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.tasks-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tasks-section h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.tasks-section h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-list {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
mat-list-item {
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 4px solid #3f51b5;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
height: auto !important;
|
||||
min-height: 48px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
mat-list-item:hover {
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
[matListItemTitle] {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[matListItemLine] {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
display: block;
|
||||
max-height: none;
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
white-space: pre-line;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.no-tasks {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.no-tasks {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
lib-task-form {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
lib-task-form {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.back-button {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.back-button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class UserDetailsComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private usersService = inject(UsersService);
|
||||
private tasksService = inject(TasksService);
|
||||
export class UserDetailsComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly usersService = inject(UsersService);
|
||||
private readonly tasksService = inject(TasksService);
|
||||
|
||||
user = signal<User | null>(null);
|
||||
userTasks = signal<TaskResponse[]>([]);
|
||||
loading = signal(true);
|
||||
readonly user = signal<User | null>(null);
|
||||
readonly userTasks = signal<TaskResponse[]>([]);
|
||||
readonly loading = signal(true);
|
||||
|
||||
ngOnInit() {
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.route.params.subscribe((params) => {
|
||||
const userId = +params['id'];
|
||||
this.loadUserDetails(userId);
|
||||
this.loadUserTasks(userId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadUserDetails(userId: number) {
|
||||
@ -136,7 +411,7 @@ export class UserDetailsComponent implements OnInit {
|
||||
|
||||
this.tasksService.getTasks().subscribe({
|
||||
next: (tasks) => {
|
||||
this.userTasks.set(tasks.filter((task) => task.tasks.user_id === id));
|
||||
this.userTasks.set(tasks.filter((task) => task.tasks.userId === id));
|
||||
},
|
||||
error: (error) => console.error('Error fetching tasks:', error),
|
||||
});
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, effect, inject, signal } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { AddUserDialogComponent } from './add-user-dialog.component';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
@ -11,6 +16,7 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@ -19,15 +25,19 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { CommentStoreService, TasksStoreService, UserStoreService } from '@org/shared/stores';
|
||||
import { ToastService } from '@org/shared/toast';
|
||||
import { CommentResponse, TaskResponse, User } from '@org/shared/api';
|
||||
import {
|
||||
CommentStoreService,
|
||||
TasksStoreService,
|
||||
UserStoreService,
|
||||
} from '@org/shared/stores';
|
||||
import { ToastService } from '@org/shared/toast';
|
||||
import { AddUserDialogComponent } from './add-user-dialog.component';
|
||||
|
||||
type UserWithCounts = User & { taskCount: number; commentCount: number };
|
||||
|
||||
@Component({
|
||||
selector: 'lib-user-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatFormFieldModule,
|
||||
@ -42,8 +52,8 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
||||
MatCardModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<mat-toolbar color="primary">User Management</mat-toolbar>
|
||||
<div class="container">
|
||||
<div class="search-container">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
@ -65,13 +75,13 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
||||
<div class="loading-container">
|
||||
<mat-progress-spinner mode="indeterminate" />
|
||||
</div>
|
||||
} @else if (filteredUsers.length === 0) {
|
||||
} @else if (filteredUsers().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No users found. Try adjusting your search or add a new user.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="user-grid">
|
||||
@for (user of filteredUsers; track user.id) {
|
||||
@for (user of filteredUsers(); track user.id) {
|
||||
<mat-card class="user-card">
|
||||
<button
|
||||
mat-icon-button
|
||||
@ -83,11 +93,9 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
||||
</button>
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<a
|
||||
[routerLink]="['/users', user.id]"
|
||||
class="user-link"
|
||||
>{{ user.name }}</a
|
||||
>
|
||||
<a [routerLink]="['/users', user.id]" class="user-link">{{
|
||||
user.name
|
||||
}}</a>
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
@ -118,7 +126,7 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
@ -266,49 +274,50 @@ export class UserListComponent {
|
||||
private readonly commentsStore = inject(CommentStoreService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
protected searchControl = new FormControl('');
|
||||
protected isLoading = signal(true);
|
||||
|
||||
filteredUsers: UserWithCounts[] = [];
|
||||
private allUsersWithCounts: (User & { taskCount: number; commentCount: number })[] = [];
|
||||
|
||||
constructor() {
|
||||
this.userStore.getUsers();
|
||||
effect(() => {
|
||||
const users = this.userStore.users();
|
||||
const tasks = this.taskStore.tasks?.() ?? [];
|
||||
const comments = this.commentsStore.comments?.() ?? [];
|
||||
if (Array.isArray(users)) {
|
||||
this.allUsersWithCounts = addCountsToUsers(users, tasks, comments);
|
||||
this.isLoading.set(false);
|
||||
this.applyFilter();
|
||||
}
|
||||
});
|
||||
this.searchControl.valueChanges.subscribe(() => this.applyFilter());
|
||||
}
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
userForm: FormGroup = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
});
|
||||
readonly searchControl = new FormControl('');
|
||||
readonly isLoading = signal(true);
|
||||
|
||||
applyFilter() {
|
||||
private readonly allUsersWithCounts = signal<UserWithCounts[]>([]);
|
||||
|
||||
readonly filteredUsers = computed(() => {
|
||||
const term = this.searchControl.value?.toLocaleLowerCase() ?? '';
|
||||
this.filteredUsers = this.allUsersWithCounts
|
||||
.filter(
|
||||
return this.allUsersWithCounts().filter(
|
||||
(u) =>
|
||||
u.name?.toLocaleLowerCase().includes(term) ||
|
||||
u.email?.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
readonly userForm: FormGroup = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.userStore.getUsers();
|
||||
this.taskStore.getTasks();
|
||||
this.commentsStore.getComments();
|
||||
|
||||
effect(() => {
|
||||
const users = this.userStore.users();
|
||||
const tasks = this.taskStore.tasks();
|
||||
const comments = this.commentsStore.comments();
|
||||
if (Array.isArray(users)) {
|
||||
this.allUsersWithCounts.set(addCountsToUsers(users, tasks, comments));
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
this.searchControl.valueChanges.subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
deleteUser(id: number) {
|
||||
if (confirm('Are you sure you want to delete this user?')) {
|
||||
this.userStore.deleteUser(id);
|
||||
this.toastService.show('User deleted successfully');
|
||||
this.applyFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,20 +334,24 @@ export class UserListComponent {
|
||||
if (result) {
|
||||
this.userStore.createUser(result);
|
||||
this.toastService.show('User created successfully');
|
||||
this.applyFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addCountsToUsers(users: User[], tasks: TaskResponse[], comments: CommentResponse[]) {
|
||||
function addCountsToUsers(
|
||||
users: User[],
|
||||
tasks: TaskResponse[],
|
||||
comments: CommentResponse[]
|
||||
) {
|
||||
return users.map((user) => {
|
||||
const userTasks = tasks.filter((t) => t.tasks.user_id === user.id);
|
||||
const userComments = comments.filter((c) => c.comments.user_id === user.id);
|
||||
const userTasks = tasks.filter((t) => t.tasks.userId === user.id);
|
||||
const userComments = comments.filter((c) => c.comments.userId === user.id);
|
||||
|
||||
return {
|
||||
...user,
|
||||
taskCount: userTasks.length,
|
||||
commentCount: userComments.length,
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@ -8,7 +8,8 @@ export const getTasks = async (c: Context) => {
|
||||
const tasks = await db
|
||||
.select()
|
||||
.from(tasksTable)
|
||||
.leftJoin(usersTable, eq(tasksTable.userId, usersTable.id));
|
||||
.leftJoin(usersTable, eq(tasksTable.userId, usersTable.id))
|
||||
.orderBy(tasksTable.timestamp);
|
||||
|
||||
if (tasks.length === 0) return c.json({ error: "No tasks found" }, 404);
|
||||
|
||||
|
||||
Binary file not shown.
@ -3,7 +3,6 @@ import "dotenv/config";
|
||||
import { db } from "./index";
|
||||
import { commentsTable, tasksTable, usersTable } from "./schema";
|
||||
|
||||
// Configuration
|
||||
const NUM_USERS = 10;
|
||||
const TASKS_PER_USER_MIN = 1;
|
||||
const TASKS_PER_USER_MAX = 5;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user