549 lines
15 KiB
TypeScript
549 lines
15 KiB
TypeScript
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 {
|
|
FormBuilder,
|
|
FormGroup,
|
|
ReactiveFormsModule,
|
|
Validators,
|
|
} from '@angular/forms';
|
|
import { MatBadgeModule } from '@angular/material/badge';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatCardModule } from '@angular/material/card';
|
|
import { MatDividerModule } from '@angular/material/divider';
|
|
import { MatExpansionModule } from '@angular/material/expansion';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatListModule } from '@angular/material/list';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { RouterModule } from '@angular/router';
|
|
import {
|
|
CommentResponse,
|
|
CommentsService,
|
|
TaskResponse,
|
|
TasksService,
|
|
User,
|
|
UsersService,
|
|
} from '@org/shared/api';
|
|
import { ToastService } from '@org/shared/toast';
|
|
import { catchError, finalize, forkJoin, of } from 'rxjs';
|
|
|
|
@Component({
|
|
selector: 'lib-task-list',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
RouterModule,
|
|
MatCardModule,
|
|
MatListModule,
|
|
MatProgressSpinnerModule,
|
|
MatToolbarModule,
|
|
MatIconModule,
|
|
MatButtonModule,
|
|
MatExpansionModule,
|
|
ReactiveFormsModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatDividerModule,
|
|
MatBadgeModule,
|
|
MatTooltipModule,
|
|
],
|
|
template: `
|
|
<div class="page-container">
|
|
<mat-toolbar color="primary" class="page-header">
|
|
<span>Task Management</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()">
|
|
<mat-icon>add</mat-icon>
|
|
</button>
|
|
|
|
@if (loading()) {
|
|
<div class="loading-container">
|
|
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
|
|
<p>Loading tasks...</p>
|
|
</div>
|
|
} @else if (tasks().length === 0) {
|
|
<div class="empty-state">
|
|
<mat-icon class="empty-icon">assignment</mat-icon>
|
|
<h2>No Tasks Found</h2>
|
|
<p>There are currently no tasks in the system.</p>
|
|
</div>
|
|
} @else {
|
|
<div class="task-container">
|
|
@for (task of tasks(); track task.tasks.id) {
|
|
<mat-expansion-panel class="task-panel">
|
|
<mat-expansion-panel-header>
|
|
<mat-panel-title>
|
|
<div class="task-title-container">
|
|
<mat-icon>assignment</mat-icon>
|
|
<span>{{ task.tasks.title }}</span>
|
|
@if (getTaskComments(task.tasks.id).length) {
|
|
<mat-icon
|
|
class="comment-badge"
|
|
matBadge="{{ getTaskComments(task.tasks.id).length }}"
|
|
matBadgeColor="accent"
|
|
>
|
|
comment
|
|
</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>
|
|
</div>
|
|
</mat-panel-description>
|
|
</mat-expansion-panel-header>
|
|
|
|
<div class="task-details">
|
|
<p class="task-description">
|
|
{{ task.tasks.description }}
|
|
</p>
|
|
|
|
<mat-divider></mat-divider>
|
|
|
|
<div class="comments-section">
|
|
<h3>
|
|
<mat-icon>forum</mat-icon>
|
|
Comments
|
|
</h3>
|
|
|
|
@if (getTaskComments(task.tasks.id).length) {
|
|
<div class="comments-list">
|
|
@for (comment of getTaskComments(task.tasks.id); track
|
|
comment.comments.id) {
|
|
<div class="comment-item">
|
|
<div class="comment-header">
|
|
<span class="comment-author">{{ comment.users.name }}</span>
|
|
<span class="comment-time">{{
|
|
formatDate(comment.comments.timestamp)
|
|
}}</span>
|
|
</div>
|
|
<p class="comment-content">{{ comment.comments.content }}</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
} @else {
|
|
<p class="no-comments">No comments yet.</p>
|
|
}
|
|
|
|
<form
|
|
[formGroup]="commentForm"
|
|
(ngSubmit)="addComment(task.tasks.id)"
|
|
class="comment-form"
|
|
>
|
|
<h4>Add a comment</h4>
|
|
<div class="form-row">
|
|
<mat-form-field appearance="outline" class="full-width">
|
|
<mat-label>Your comment</mat-label>
|
|
<textarea
|
|
matInput
|
|
formControlName="content"
|
|
placeholder="Share your thoughts..."
|
|
rows="2"
|
|
></textarea>
|
|
@if (commentForm.get('content')?.hasError('required') &&
|
|
commentForm.get('content')?.touched) {
|
|
<mat-error>Comment text is required</mat-error>
|
|
}
|
|
</mat-form-field>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>Post as</mat-label>
|
|
<select matNativeControl formControlName="userId" required>
|
|
<option value="" disabled selected>Select user</option>
|
|
@for (user of users(); track user.id) {
|
|
<option [value]="user.id">{{ user.name }}</option>
|
|
}
|
|
</select>
|
|
@if (commentForm.get('userId')?.hasError('required') &&
|
|
commentForm.get('userId')?.touched) {
|
|
<mat-error>User is required</mat-error>
|
|
}
|
|
</mat-form-field>
|
|
|
|
<button
|
|
mat-raised-button
|
|
color="primary"
|
|
type="submit"
|
|
[disabled]="commentForm.invalid || addingComment()"
|
|
>
|
|
@if (addingComment()) {
|
|
<mat-spinner
|
|
diameter="20"
|
|
class="button-spinner"
|
|
></mat-spinner>
|
|
} @else {
|
|
<mat-icon>send</mat-icon>
|
|
}
|
|
<span>{{
|
|
addingComment() ? 'Posting...' : 'Post Comment'
|
|
}}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</mat-expansion-panel>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
`,
|
|
styles: [
|
|
`
|
|
.fab {
|
|
position: fixed;
|
|
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;
|
|
}
|
|
|
|
.spacer {
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.loading-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin: 4rem 0;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: #f5f5f5;
|
|
border-radius: 8px;
|
|
padding: 3rem;
|
|
margin: 2rem 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 4rem;
|
|
height: 4rem;
|
|
width: 4rem;
|
|
color: #bdbdbd;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.task-container {
|
|
margin: 1rem 0;
|
|
}
|
|
|
|
.task-panel {
|
|
margin-bottom: 1rem;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.task-title-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.comment-badge {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.task-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.task-author {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.task-timestamp {
|
|
font-size: 0.8rem;
|
|
color: #757575;
|
|
}
|
|
|
|
.task-details {
|
|
padding: 1rem 0;
|
|
}
|
|
|
|
.task-description {
|
|
font-size: 1rem;
|
|
line-height: 1.5;
|
|
margin-bottom: 1.5rem;
|
|
white-space: pre-line;
|
|
}
|
|
|
|
.comments-section {
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.comments-section h3 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 1.2rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.comments-list {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.comment-item {
|
|
padding: 1rem;
|
|
background-color: #f5f5f5;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.comment-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.comment-author {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.comment-time {
|
|
font-size: 0.8rem;
|
|
color: #757575;
|
|
}
|
|
|
|
.comment-content {
|
|
margin: 0;
|
|
white-space: pre-line;
|
|
}
|
|
|
|
.no-comments {
|
|
font-style: italic;
|
|
color: #757575;
|
|
text-align: center;
|
|
padding: 1rem;
|
|
background-color: #f5f5f5;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.comment-form {
|
|
background-color: #f9f9f9;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.comment-form h4 {
|
|
margin-top: 0;
|
|
margin-bottom: 1rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.form-row {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.full-width {
|
|
width: 100%;
|
|
}
|
|
|
|
button {
|
|
height: 56px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.button-spinner {
|
|
margin-right: 0.5rem;
|
|
}
|
|
`,
|
|
],
|
|
})
|
|
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);
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
commentForm: FormGroup = this.fb.group({
|
|
content: ['', Validators.required],
|
|
userId: ['', Validators.required],
|
|
});
|
|
|
|
ngOnInit() {
|
|
this.loadData();
|
|
}
|
|
|
|
loadData() {
|
|
this.loading.set(true);
|
|
|
|
forkJoin({
|
|
users: this.usersService.getUsers(),
|
|
tasks: this.tasksService.getTasks(),
|
|
comments: this.commentsService.getComments(),
|
|
})
|
|
.pipe(
|
|
catchError((error) => {
|
|
console.error('Error loading data:', error);
|
|
this.toastService.show(
|
|
'Failed to load data. Please try again.',
|
|
'error'
|
|
);
|
|
return of({ users: [], tasks: [], comments: [] });
|
|
}),
|
|
finalize(() => this.loading.set(false))
|
|
)
|
|
.subscribe({
|
|
next: (data) => {
|
|
this.users.set(data.users);
|
|
this.tasks.set(data.tasks);
|
|
this.comments.set(data.comments);
|
|
},
|
|
});
|
|
}
|
|
|
|
getUserName(userId: number): string {
|
|
const user = this.users().find((u) => u.id === userId);
|
|
return user ? user.name : 'Unknown User';
|
|
}
|
|
|
|
getTaskComments(taskId: number): CommentResponse[] {
|
|
return this.comments().filter(
|
|
(comment) => comment.comments.task_id === taskId
|
|
);
|
|
}
|
|
|
|
formatDate(timestamp?: string): string {
|
|
if (!timestamp) return 'Unknown date';
|
|
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 0) {
|
|
return (
|
|
'Today at ' +
|
|
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
);
|
|
} else if (diffDays === 1) {
|
|
return (
|
|
'Yesterday at ' +
|
|
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
);
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else {
|
|
return date.toLocaleDateString([], {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
}
|
|
|
|
addComment(taskId: number) {
|
|
if (this.commentForm.invalid) return;
|
|
|
|
this.addingComment.set(true);
|
|
|
|
const comment = {
|
|
content: this.commentForm.value.content,
|
|
task_id: taskId,
|
|
user_id: parseInt(this.commentForm.value.userId),
|
|
};
|
|
|
|
this.commentsService
|
|
.createComment(comment)
|
|
.pipe(finalize(() => this.addingComment.set(false)))
|
|
.subscribe({
|
|
next: (newComment) => {
|
|
this.comments.update((comments) => [...comments, newComment]);
|
|
this.commentForm.reset({ content: '', userId: '' });
|
|
this.toastService.show('Comment added successfully!');
|
|
},
|
|
error: (error) => {
|
|
console.error('Error adding comment:', error);
|
|
this.toastService.show(
|
|
'Failed to add comment! Please try again.',
|
|
'error'
|
|
);
|
|
},
|
|
});
|
|
}
|
|
}
|