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;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
user_id: number;
|
userId: number;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -21,8 +21,8 @@ export type TaskResponse = {
|
|||||||
export type Comment = {
|
export type Comment = {
|
||||||
id: number;
|
id: number;
|
||||||
content: string;
|
content: string;
|
||||||
task_id: number;
|
taskId: number;
|
||||||
user_id: number;
|
userId: number;
|
||||||
timestamp?: string;
|
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 { MatIcon } from '@angular/material/icon';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lib-navbar',
|
selector: 'lib-navbar',
|
||||||
imports: [MatToolbarModule, MatIcon],
|
imports: [MatToolbarModule, MatIcon, MatButtonModule, RouterModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<mat-toolbar color="primary" class="navbar">
|
<mat-toolbar color="primary" class="navbar">
|
||||||
<span class="logo">
|
<span class="logo">
|
||||||
@ -36,17 +39,6 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
|||||||
.spacer {
|
.spacer {
|
||||||
flex: 1 1 auto;
|
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-list.component';
|
||||||
export * from './lib/task-form.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 { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog';
|
import {
|
||||||
import { TaskFormComponent } from './task-form.component';
|
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({
|
@Component({
|
||||||
selector: 'lib-add-task-dialog',
|
selector: 'lib-add-task-dialog',
|
||||||
standalone: true,
|
|
||||||
template: `
|
template: `
|
||||||
<h2 mat-dialog-title>Add New Task</h2>
|
<h2 mat-dialog-title class="dialog-title">Add New Task</h2>
|
||||||
<mat-dialog-content>
|
<mat-dialog-content class="dialog-content">
|
||||||
<lib-task-form (taskAdded)="onTaskAdded()"></lib-task-form>
|
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
|
||||||
</mat-dialog-content>
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
<mat-dialog-actions align="end">
|
<mat-label>Assigned to</mat-label>
|
||||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
<mat-select formControlName="userId" required>
|
||||||
</mat-dialog-actions>
|
@for (user of users(); track user.id) {
|
||||||
`,
|
<mat-option [value]="user.id">{{ user.name }}</mat-option>
|
||||||
styles: [`
|
|
||||||
mat-dialog-content {
|
|
||||||
min-width: 320px;
|
|
||||||
}
|
}
|
||||||
`],
|
</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: [
|
imports: [
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDialogActions,
|
MatDialogActions,
|
||||||
MatDialogClose,
|
MatDialogClose,
|
||||||
MatDialogTitle,
|
MatDialogTitle,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
TaskFormComponent,
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class AddTaskDialogComponent {
|
export class AddTaskDialogComponent {
|
||||||
readonly dialogRef = inject(MatDialogRef<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);
|
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 { CommonModule } from '@angular/common';
|
||||||
import { Component, EventEmitter, Output, inject, input } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@ -9,25 +15,27 @@ import {
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { TasksService } from '@org/shared/api';
|
import { TasksService } from '@org/shared/api';
|
||||||
import { ToastService } from '@org/shared/toast';
|
import { ToastService } from '@org/shared/toast';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lib-task-form',
|
selector: 'lib-task-form',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
],
|
],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<mat-card>
|
<mat-card class="task-form-card">
|
||||||
<mat-card-header>
|
<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-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
|
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
|
||||||
@ -46,41 +54,79 @@ import { ToastService } from '@org/shared/toast';
|
|||||||
matInput
|
matInput
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Task description"
|
placeholder="Task description"
|
||||||
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="primary"
|
color="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
[disabled]="taskForm.invalid"
|
[disabled]="taskForm.invalid"
|
||||||
>
|
>
|
||||||
Add Task
|
<mat-icon>add_task</mat-icon>
|
||||||
|
<span class="button-text">Add Task</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
.full-width {
|
.task-form-card {
|
||||||
width: 100%;
|
margin: 2rem 0;
|
||||||
margin-bottom: 1rem;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.form-title {
|
||||||
margin-top: 1rem;
|
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 {
|
export class TaskFormComponent {
|
||||||
userId = input<number>(0);
|
readonly userId = input<number>(0);
|
||||||
@Output() taskAdded = new EventEmitter<void>();
|
readonly taskAdded = output<void>();
|
||||||
|
|
||||||
private fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private tasksService = inject(TasksService);
|
private readonly tasksService = inject(TasksService);
|
||||||
private toastService = inject(ToastService);
|
private readonly toastService = inject(ToastService);
|
||||||
|
|
||||||
taskForm: FormGroup = this.fb.group({
|
taskForm: FormGroup = this.fb.group({
|
||||||
title: ['', Validators.required],
|
title: ['', Validators.required],
|
||||||
@ -93,7 +139,7 @@ export class TaskFormComponent {
|
|||||||
const task = {
|
const task = {
|
||||||
title: this.taskForm.value.title,
|
title: this.taskForm.value.title,
|
||||||
description: this.taskForm.value.description,
|
description: this.taskForm.value.description,
|
||||||
user_id: this.userId(),
|
userId: this.userId(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.tasksService.createTask(task).subscribe({
|
this.tasksService.createTask(task).subscribe({
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import {
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
ChangeDetectionStrategy,
|
||||||
import { AddTaskDialogComponent } from './add-task-dialog.component';
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@ -11,6 +14,7 @@ import {
|
|||||||
import { MatBadgeModule } from '@angular/material/badge';
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatDividerModule } from '@angular/material/divider';
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
import { MatExpansionModule } from '@angular/material/expansion';
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
@ -31,10 +35,10 @@ import {
|
|||||||
} from '@org/shared/api';
|
} from '@org/shared/api';
|
||||||
import { ToastService } from '@org/shared/toast';
|
import { ToastService } from '@org/shared/toast';
|
||||||
import { catchError, finalize, forkJoin, of } from 'rxjs';
|
import { catchError, finalize, forkJoin, of } from 'rxjs';
|
||||||
|
import { AddTaskDialogComponent } from './add-task-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lib-task-list',
|
selector: 'lib-task-list',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
@ -52,17 +56,18 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
MatBadgeModule,
|
MatBadgeModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
],
|
],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<mat-toolbar color="primary" class="page-header">
|
<mat-toolbar color="primary" class="page-header">
|
||||||
<span>Task Management</span>
|
<span>Tasks</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button mat-icon-button matTooltip="Refresh data" (click)="loadData()">
|
<button mat-icon-button matTooltip="Refresh data" (click)="loadData()">
|
||||||
<mat-icon>refresh</mat-icon>
|
<mat-icon>refresh</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
|
||||||
<button class="fab" mat-fab color="accent" (click)="openAddTaskDialog()">
|
<button class="fab" matFab color="accent" (click)="openAddTaskDialog()">
|
||||||
<mat-icon>add</mat-icon>
|
<mat-icon>add</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -86,6 +91,14 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
<div class="task-title-container">
|
<div class="task-title-container">
|
||||||
<mat-icon>assignment</mat-icon>
|
<mat-icon>assignment</mat-icon>
|
||||||
<span>{{ task.tasks.title }}</span>
|
<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) {
|
@if (getTaskComments(task.tasks.id).length) {
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="comment-badge"
|
class="comment-badge"
|
||||||
@ -96,12 +109,6 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
</mat-icon>
|
</mat-icon>
|
||||||
}
|
}
|
||||||
</div>
|
</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">{{
|
<span class="task-timestamp">{{
|
||||||
formatDate(task.tasks.timestamp)
|
formatDate(task.tasks.timestamp)
|
||||||
}}</span>
|
}}</span>
|
||||||
@ -213,29 +220,20 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
bottom: 2rem;
|
bottom: 2rem;
|
||||||
right: 2rem;
|
right: 2rem;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
}
|
|
||||||
.dialog-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1200;
|
|
||||||
}
|
|
||||||
.dialog-card {
|
|
||||||
min-width: 320px;
|
|
||||||
max-width: 90vw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 2rem;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
@ -256,11 +254,12 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 3rem;
|
padding: 4rem 2rem;
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
@ -271,33 +270,86 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
color: #424242;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
|
||||||
.task-container {
|
.task-container {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-panel {
|
.task-panel {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
border-radius: 8px;
|
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 {
|
.task-title-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-badge {
|
.task-title-container mat-icon {
|
||||||
margin-left: 0.5rem;
|
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 {
|
.task-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
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 {
|
.task-author {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #424242;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-timestamp {
|
.task-timestamp {
|
||||||
@ -306,18 +358,23 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-details {
|
.task-details {
|
||||||
padding: 1rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-description {
|
.task-description {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 2rem;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
|
color: #424242;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #1976d2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-section {
|
.comments-section {
|
||||||
margin-top: 1.5rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-section h3 {
|
.comments-section h3 {
|
||||||
@ -325,28 +382,42 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
|
color: #424242;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section h3 mat-icon {
|
||||||
|
color: #1976d2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-list {
|
.comments-list {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-item {
|
.comment-item {
|
||||||
padding: 1rem;
|
padding: 1.25rem;
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
margin-bottom: 1rem;
|
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 {
|
.comment-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0.5rem;
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-author {
|
.comment-author {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
color: #1976d2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-time {
|
.comment-time {
|
||||||
@ -357,38 +428,56 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
.comment-content {
|
.comment-content {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
|
color: #424242;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-comments {
|
.no-comments {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #757575;
|
color: #757575;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1rem;
|
padding: 2rem;
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
|
border: 2px dashed #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-form {
|
.comment-form {
|
||||||
background-color: #f9f9f9;
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 1rem;
|
padding: 1.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1.5rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-form h4 {
|
.comment-form h4 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
font-size: 1rem;
|
font-size: 1.1rem;
|
||||||
|
color: #424242;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form h4::before {
|
||||||
|
content: '💬';
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions mat-form-field {
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-width {
|
.full-width {
|
||||||
@ -400,51 +489,54 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-spinner {
|
.button-spinner {
|
||||||
margin-right: 0.5rem;
|
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 {
|
export class TaskListComponent {
|
||||||
private tasksService = inject(TasksService);
|
private readonly tasksService = inject(TasksService);
|
||||||
private usersService = inject(UsersService);
|
private readonly usersService = inject(UsersService);
|
||||||
private commentsService = inject(CommentsService);
|
private readonly commentsService = inject(CommentsService);
|
||||||
private fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private toastService = inject(ToastService);
|
private readonly toastService = inject(ToastService);
|
||||||
|
|
||||||
tasks = signal<TaskResponse[]>([]);
|
|
||||||
users = signal<User[]>([]);
|
|
||||||
comments = signal<CommentResponse[]>([]);
|
|
||||||
loading = signal(true);
|
|
||||||
addingComment = signal(false);
|
|
||||||
|
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
openAddTaskDialog() {
|
readonly tasks = signal<TaskResponse[]>([]);
|
||||||
const dialogRef = this.dialog.open(AddTaskDialogComponent, {
|
readonly users = signal<User[]>([]);
|
||||||
width: '400px',
|
readonly comments = signal<CommentResponse[]>([]);
|
||||||
enterAnimationDuration: '220ms',
|
readonly loading = signal(true);
|
||||||
exitAnimationDuration: '180ms',
|
readonly addingComment = signal(false);
|
||||||
autoFocus: true,
|
|
||||||
restoreFocus: true,
|
|
||||||
disableClose: false,
|
|
||||||
});
|
|
||||||
dialogRef.afterClosed().subscribe(result => {
|
|
||||||
if (result) {
|
|
||||||
this.loadData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
commentForm: FormGroup = this.fb.group({
|
readonly commentForm: FormGroup = this.fb.group({
|
||||||
content: ['', Validators.required],
|
content: ['', Validators.required],
|
||||||
userId: ['', Validators.required],
|
userId: ['', Validators.required],
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit() {
|
constructor() {
|
||||||
this.loadData();
|
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 {
|
getUserName(userId: number): string {
|
||||||
const user = this.users().find((u) => u.id === userId);
|
const user = this.users().find((u) => u.id === userId);
|
||||||
return user ? user.name : 'Unknown User';
|
return user ? user.name : 'Unknown User';
|
||||||
@ -483,7 +591,7 @@ export class TaskListComponent implements OnInit {
|
|||||||
|
|
||||||
getTaskComments(taskId: number): CommentResponse[] {
|
getTaskComments(taskId: number): CommentResponse[] {
|
||||||
return this.comments().filter(
|
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 = {
|
const comment = {
|
||||||
content: this.commentForm.value.content,
|
content: this.commentForm.value.content,
|
||||||
task_id: taskId,
|
taskId: taskId,
|
||||||
user_id: parseInt(this.commentForm.value.userId),
|
userId: parseInt(this.commentForm.value.userId),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.commentsService
|
this.commentsService
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
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 { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@ -17,7 +23,6 @@ import { TaskFormComponent } from '@org/web/tasks';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lib-user-details',
|
selector: 'lib-user-details',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
@ -29,41 +34,77 @@ import { TaskFormComponent } from '@org/web/tasks';
|
|||||||
MatToolbarModule,
|
MatToolbarModule,
|
||||||
TaskFormComponent,
|
TaskFormComponent,
|
||||||
],
|
],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<mat-toolbar color="primary">User Details</mat-toolbar>
|
<mat-toolbar color="primary">
|
||||||
|
<span>User Details</span>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-container">
|
<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>
|
</div>
|
||||||
} @else if (user()) {
|
} @else if (user()) {
|
||||||
|
<div class="user-container">
|
||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
|
<mat-icon aria-hidden="true">account_circle</mat-icon>
|
||||||
|
<div>
|
||||||
<mat-card-title>{{ user()?.name }}</mat-card-title>
|
<mat-card-title>{{ user()?.name }}</mat-card-title>
|
||||||
<mat-card-subtitle>{{ user()?.email }}</mat-card-subtitle>
|
<mat-card-subtitle>{{ user()?.email }}</mat-card-subtitle>
|
||||||
|
</div>
|
||||||
</mat-card-header>
|
</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>
|
<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-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<div class="tasks-section">
|
<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) {
|
@if (userTasks().length) {
|
||||||
<mat-list>
|
<mat-list>
|
||||||
@for (task of userTasks(); track task.tasks.id) {
|
@for (task of userTasks(); track task.tasks.id) {
|
||||||
<mat-list-item>
|
<mat-list-item>
|
||||||
<mat-icon matListItemIcon>assignment</mat-icon>
|
<mat-icon
|
||||||
<h3 matListItemTitle>{{ task.tasks.title }}</h3>
|
matListItemIcon
|
||||||
<p matListItemLine>
|
[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' }}
|
{{ task.tasks.description || 'No description' }}
|
||||||
</p>
|
</div>
|
||||||
</mat-list-item>
|
</mat-list-item>
|
||||||
}
|
}
|
||||||
</mat-list>
|
</mat-list>
|
||||||
} @else {
|
} @else {
|
||||||
|
<div class="no-tasks">
|
||||||
|
<mat-icon aria-hidden="true">assignment_late</mat-icon>
|
||||||
<p>No tasks found for this user.</p>
|
<p>No tasks found for this user.</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<lib-task-form
|
<lib-task-form
|
||||||
@ -71,15 +112,42 @@ import { TaskFormComponent } from '@org/web/tasks';
|
|||||||
(taskAdded)="loadUserTasks()"
|
(taskAdded)="loadUserTasks()"
|
||||||
></lib-task-form>
|
></lib-task-form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="error-container">
|
<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>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
:host {
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loading-container,
|
.loading-container,
|
||||||
.error-container {
|
.error-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -89,31 +157,238 @@ import { TaskFormComponent } from '@org/web/tasks';
|
|||||||
margin: 2rem 0;
|
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 {
|
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 {
|
.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;
|
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 {
|
export class UserDetailsComponent {
|
||||||
private route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private usersService = inject(UsersService);
|
private readonly usersService = inject(UsersService);
|
||||||
private tasksService = inject(TasksService);
|
private readonly tasksService = inject(TasksService);
|
||||||
|
|
||||||
user = signal<User | null>(null);
|
readonly user = signal<User | null>(null);
|
||||||
userTasks = signal<TaskResponse[]>([]);
|
readonly userTasks = signal<TaskResponse[]>([]);
|
||||||
loading = signal(true);
|
readonly loading = signal(true);
|
||||||
|
|
||||||
ngOnInit() {
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
this.route.params.subscribe((params) => {
|
this.route.params.subscribe((params) => {
|
||||||
const userId = +params['id'];
|
const userId = +params['id'];
|
||||||
this.loadUserDetails(userId);
|
this.loadUserDetails(userId);
|
||||||
this.loadUserTasks(userId);
|
this.loadUserTasks(userId);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadUserDetails(userId: number) {
|
loadUserDetails(userId: number) {
|
||||||
@ -136,7 +411,7 @@ export class UserDetailsComponent implements OnInit {
|
|||||||
|
|
||||||
this.tasksService.getTasks().subscribe({
|
this.tasksService.getTasks().subscribe({
|
||||||
next: (tasks) => {
|
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),
|
error: (error) => console.error('Error fetching tasks:', error),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, effect, inject, signal } from '@angular/core';
|
import {
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
ChangeDetectionStrategy,
|
||||||
import { AddUserDialogComponent } from './add-user-dialog.component';
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -11,6 +16,7 @@ import {
|
|||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatDividerModule } from '@angular/material/divider';
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@ -19,15 +25,19 @@ import { MatListModule } from '@angular/material/list';
|
|||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
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 { 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 };
|
type UserWithCounts = User & { taskCount: number; commentCount: number };
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lib-user-list',
|
selector: 'lib-user-list',
|
||||||
standalone: true,
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
@ -42,8 +52,8 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
|||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatDividerModule,
|
MatDividerModule,
|
||||||
],
|
],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<mat-toolbar color="primary">User Management</mat-toolbar>
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
@ -65,13 +75,13 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
|||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<mat-progress-spinner mode="indeterminate" />
|
<mat-progress-spinner mode="indeterminate" />
|
||||||
</div>
|
</div>
|
||||||
} @else if (filteredUsers.length === 0) {
|
} @else if (filteredUsers().length === 0) {
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>No users found. Try adjusting your search or add a new user.</p>
|
<p>No users found. Try adjusting your search or add a new user.</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="user-grid">
|
<div class="user-grid">
|
||||||
@for (user of filteredUsers; track user.id) {
|
@for (user of filteredUsers(); track user.id) {
|
||||||
<mat-card class="user-card">
|
<mat-card class="user-card">
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
@ -83,11 +93,9 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
|||||||
</button>
|
</button>
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>
|
<mat-card-title>
|
||||||
<a
|
<a [routerLink]="['/users', user.id]" class="user-link">{{
|
||||||
[routerLink]="['/users', user.id]"
|
user.name
|
||||||
class="user-link"
|
}}</a>
|
||||||
>{{ user.name }}</a
|
|
||||||
>
|
|
||||||
</mat-card-title>
|
</mat-card-title>
|
||||||
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
|
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
@ -118,7 +126,7 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
.full-width {
|
.full-width {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -266,49 +274,50 @@ export class UserListComponent {
|
|||||||
private readonly commentsStore = inject(CommentStoreService);
|
private readonly commentsStore = inject(CommentStoreService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly toastService = inject(ToastService);
|
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);
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
userForm: FormGroup = this.fb.group({
|
readonly searchControl = new FormControl('');
|
||||||
name: ['', Validators.required],
|
readonly isLoading = signal(true);
|
||||||
email: ['', [Validators.required, Validators.email]],
|
|
||||||
});
|
|
||||||
|
|
||||||
applyFilter() {
|
private readonly allUsersWithCounts = signal<UserWithCounts[]>([]);
|
||||||
|
|
||||||
|
readonly filteredUsers = computed(() => {
|
||||||
const term = this.searchControl.value?.toLocaleLowerCase() ?? '';
|
const term = this.searchControl.value?.toLocaleLowerCase() ?? '';
|
||||||
this.filteredUsers = this.allUsersWithCounts
|
return this.allUsersWithCounts().filter(
|
||||||
.filter(
|
|
||||||
(u) =>
|
(u) =>
|
||||||
u.name?.toLocaleLowerCase().includes(term) ||
|
u.name?.toLocaleLowerCase().includes(term) ||
|
||||||
u.email?.toLowerCase().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) {
|
deleteUser(id: number) {
|
||||||
if (confirm('Are you sure you want to delete this user?')) {
|
if (confirm('Are you sure you want to delete this user?')) {
|
||||||
this.userStore.deleteUser(id);
|
this.userStore.deleteUser(id);
|
||||||
this.toastService.show('User deleted successfully');
|
this.toastService.show('User deleted successfully');
|
||||||
this.applyFilter();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,20 +334,24 @@ export class UserListComponent {
|
|||||||
if (result) {
|
if (result) {
|
||||||
this.userStore.createUser(result);
|
this.userStore.createUser(result);
|
||||||
this.toastService.show('User created successfully');
|
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) => {
|
return users.map((user) => {
|
||||||
const userTasks = tasks.filter((t) => t.tasks.user_id === user.id);
|
const userTasks = tasks.filter((t) => t.tasks.userId === user.id);
|
||||||
const userComments = comments.filter((c) => c.comments.user_id === user.id);
|
const userComments = comments.filter((c) => c.comments.userId === user.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
taskCount: userTasks.length,
|
taskCount: userTasks.length,
|
||||||
commentCount: userComments.length,
|
commentCount: userComments.length,
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,8 @@ export const getTasks = async (c: Context) => {
|
|||||||
const tasks = await db
|
const tasks = await db
|
||||||
.select()
|
.select()
|
||||||
.from(tasksTable)
|
.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);
|
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 { db } from "./index";
|
||||||
import { commentsTable, tasksTable, usersTable } from "./schema";
|
import { commentsTable, tasksTable, usersTable } from "./schema";
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const NUM_USERS = 10;
|
const NUM_USERS = 10;
|
||||||
const TASKS_PER_USER_MIN = 1;
|
const TASKS_PER_USER_MIN = 1;
|
||||||
const TASKS_PER_USER_MAX = 5;
|
const TASKS_PER_USER_MAX = 5;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user