diff --git a/client/libs/web/tasks/src/index.ts b/client/libs/web/tasks/src/index.ts index ec43c32..3bd131f 100644 --- a/client/libs/web/tasks/src/index.ts +++ b/client/libs/web/tasks/src/index.ts @@ -1,3 +1,5 @@ -export * from './lib/task-list.component'; -export * from './lib/task-form.component'; export * from './lib/add-task-dialog.component'; +export * from './lib/task-card.component'; +export * from './lib/task-comment.component'; +export * from './lib/task-form.component'; +export * from './lib/task-list.component'; diff --git a/client/libs/web/tasks/src/lib/add-task-dialog.component.ts b/client/libs/web/tasks/src/lib/add-task-dialog.component.ts index 260e6ed..9d3d61c 100644 --- a/client/libs/web/tasks/src/lib/add-task-dialog.component.ts +++ b/client/libs/web/tasks/src/lib/add-task-dialog.component.ts @@ -84,9 +84,7 @@ import { ToastService } from '@org/shared/toast'; ` .dialog-title { font-weight: 700; - font-size: 1.3rem; color: #1976d2; - margin-bottom: 0.5rem; } .dialog-content { min-width: 400px; @@ -94,30 +92,15 @@ import { ToastService } from '@org/shared/toast'; } .full-width { width: 100%; - margin: 1.2rem 0; + margin: 1rem 0; } .dialog-actions { - margin-top: 1.2rem; + margin-top: 1rem; } button[mat-raised-button] { display: flex; - align-items: flex-start; + align-items: center; 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 { diff --git a/client/libs/web/tasks/src/lib/task-card.component.ts b/client/libs/web/tasks/src/lib/task-card.component.ts new file mode 100644 index 0000000..6bc0196 --- /dev/null +++ b/client/libs/web/tasks/src/lib/task-card.component.ts @@ -0,0 +1,261 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, +} from '@angular/core'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { CommentResponse, TaskResponse, User } from '@org/shared/api'; +import { TaskCommentComponent } from './task-comment.component'; + +@Component({ + selector: 'lib-task-card', + imports: [ + CommonModule, + MatExpansionModule, + MatIconModule, + MatDividerModule, + MatBadgeModule, + MatButtonModule, + TaskCommentComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + +
+ assignment + {{ task().tasks.title }} +
+
+ +
+
+ By {{ getUserName(task().tasks.userId) }} + @if (taskComments().length) { + + comment + + } +
+ {{ + formatDate(task().tasks.timestamp) + }} +
+
+
+ +
+

+ {{ task().tasks.description }} +

+ + + +
+

+ forum + Comments +

+ + +
+
+
+ `, + styles: [ + ` + .task-panel { + margin-bottom: 1rem; + border-radius: 8px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + } + + .task-panel:hover { + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12); + } + + .task-panel .mat-expansion-panel-header { + height: auto; + min-height: 64px; + padding: 0.75rem 1rem; + } + + .task-title-container { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + margin-right: 1rem; + } + + .task-title-container mat-icon { + color: #1976d2; + } + + .task-title-container span { + font-weight: 500; + font-size: 1.1rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + } + + .task-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + min-width: 150px; + text-align: right; + } + + .task-info-row { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 0.5rem; + } + + .task-author { + font-weight: 500; + color: #424242; + } + + .task-timestamp { + font-size: 0.8rem; + color: #757575; + } + + .task-details { + padding: 1rem 0; + } + + .task-description { + font-size: 1rem; + line-height: 1.6; + margin-bottom: 1.5rem; + white-space: pre-line; + color: #424242; + background-color: #f8f9fa; + padding: 1rem; + border-radius: 6px; + border-left: 4px solid #1976d2; + } + + .comments-section { + margin-top: 1.5rem; + } + + .comments-section h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.2rem; + margin-bottom: 1rem; + color: #424242; + } + + .comments-section h3 mat-icon { + color: #1976d2; + } + + @media (max-width: 768px) { + .task-info { + align-items: flex-start; + } + } + `, + ], +}) +export class TaskCardComponent { + readonly task = input.required(); + readonly users = input([]); + readonly comments = input([]); + readonly addingComment = input(false); + readonly commentAdded = output<{ + taskId: number; + content: string; + userId: number; + }>(); + + readonly taskComments = signal([]); + + constructor() { + // Update task comments whenever comments or task changes + effect(() => { + const taskId = this.task()?.tasks?.id; + if (taskId && this.comments()) { + this.taskComments.set( + this.comments().filter( + (comment) => comment.comments.taskId === taskId + ) + ); + } + }); + } + + getUserName(userId: number): string { + const user = this.users().find((u) => u.id === userId); + return user ? user.name : 'Unknown User'; + } + + 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', + }); + } + } + + handleAddComment(commentData: { content: string; userId: number }) { + this.commentAdded.emit({ + taskId: this.task().tasks.id, + ...commentData, + }); + } +} diff --git a/client/libs/web/tasks/src/lib/task-comment.component.ts b/client/libs/web/tasks/src/lib/task-comment.component.ts new file mode 100644 index 0000000..5ad649e --- /dev/null +++ b/client/libs/web/tasks/src/lib/task-comment.component.ts @@ -0,0 +1,270 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + output, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { CommentResponse, User } from '@org/shared/api'; + +@Component({ + selector: 'lib-task-comment', + imports: [ + CommonModule, + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (comments().length) { +
+ @for (comment of comments(); track comment.comments.id) { +
+
+ {{ comment.users.name }} + {{ + formatDate(comment.comments.timestamp) + }} +
+

{{ comment.comments.content }}

+
+ } +
+ } @else { +

No comments yet.

+ } + +
+

Add a comment

+
+ + Your comment + + @if (commentForm.get('content')?.hasError('required') && + commentForm.get('content')?.touched) { + Comment text is required + } + +
+ +
+ + Post as + + @if (commentForm.get('userId')?.hasError('required') && + commentForm.get('userId')?.touched) { + User is required + } + + + +
+
+ `, + styles: [ + ` + .comments-list { + margin-bottom: 1.5rem; + } + + .comment-item { + padding: 1rem; + background-color: #f8f9fa; + border-radius: 8px; + margin-bottom: 0.75rem; + border: 1px solid #e0e0e0; + transition: transform 0.2s ease; + } + + .comment-item:hover { + transform: translateY(-2px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.08); + } + + .comment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + } + + .comment-author { + font-weight: 600; + color: #1976d2; + } + + .comment-time { + font-size: 0.8rem; + color: #757575; + } + + .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: 1.5rem; + background-color: #f5f5f5; + border-radius: 8px; + border: 1px dashed #e0e0e0; + } + + .comment-form { + background-color: #f8f9fa; + border-radius: 8px; + padding: 1rem; + margin-top: 1rem; + border: 1px solid #e0e0e0; + } + + .comment-form h4 { + margin-top: 0; + margin-bottom: 1rem; + font-size: 1.1rem; + color: #424242; + } + + .form-row { + margin-bottom: 1rem; + } + + .form-actions { + display: flex; + align-items: flex-start; + gap: 1rem; + } + + .form-actions mat-form-field { + min-width: 180px; + } + + .full-width { + width: 100%; + } + + button { + height: 48px; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .button-spinner { + margin-right: 0.25rem; + } + + @media (max-width: 768px) { + .form-actions { + flex-direction: column; + } + + .form-actions mat-form-field { + width: 100%; + } + } + `, + ], +}) +export class TaskCommentComponent { + readonly taskId = input.required(); + readonly comments = input([]); + readonly users = input([]); + readonly submitting = input(false); + readonly addComment = output<{ content: string; userId: number }>(); + + private readonly fb = inject(FormBuilder); + + readonly commentForm: FormGroup = this.fb.group({ + content: ['', Validators.required], + userId: ['', Validators.required], + }); + + submitComment() { + if (this.commentForm.invalid) return; + + const comment = { + content: this.commentForm.value.content, + userId: parseInt(this.commentForm.value.userId), + }; + + this.addComment.emit(comment); + this.commentForm.reset({ content: '', userId: '' }); + } + + 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', + }); + } + } +} diff --git a/client/libs/web/tasks/src/lib/task-form.component.ts b/client/libs/web/tasks/src/lib/task-form.component.ts index 8866088..20dfb52 100644 --- a/client/libs/web/tasks/src/lib/task-form.component.ts +++ b/client/libs/web/tasks/src/lib/task-form.component.ts @@ -76,46 +76,30 @@ import { ToastService } from '@org/shared/toast'; styles: [ ` .task-form-card { - margin: 2rem 0; + margin: 1.5rem 0; max-width: 600px; } .form-title { font-weight: 700; color: #1976d2; - margin-bottom: 0.5rem; } .full-width { width: 100%; - margin: 1.2rem 0; + margin: 1rem 0; } .form-actions { display: flex; justify-content: flex-end; - margin-top: 1.2rem; + margin-top: 1rem; } - button[mat-raised-button] { + button { display: flex; - align-items: flex-start; + align-items: center; 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; } `, ], diff --git a/client/libs/web/tasks/src/lib/task-list.component.ts b/client/libs/web/tasks/src/lib/task-list.component.ts index 40c3453..58ef7f7 100644 --- a/client/libs/web/tasks/src/lib/task-list.component.ts +++ b/client/libs/web/tasks/src/lib/task-list.component.ts @@ -5,22 +5,9 @@ import { inject, signal, } from '@angular/core'; -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 { MatDialog } from '@angular/material/dialog'; -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'; @@ -36,25 +23,19 @@ import { import { ToastService } from '@org/shared/toast'; import { catchError, finalize, forkJoin, of } from 'rxjs'; import { AddTaskDialogComponent } from './add-task-dialog.component'; +import { TaskCardComponent } from './task-card.component'; @Component({ selector: 'lib-task-list', imports: [ CommonModule, RouterModule, - MatCardModule, - MatListModule, MatProgressSpinnerModule, MatToolbarModule, MatIconModule, MatButtonModule, - MatExpansionModule, - ReactiveFormsModule, - MatFormFieldModule, - MatInputModule, - MatDividerModule, - MatBadgeModule, MatTooltipModule, + TaskCardComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -67,7 +48,7 @@ import { AddTaskDialogComponent } from './add-task-dialog.component'; - @@ -85,129 +66,13 @@ import { AddTaskDialogComponent } from './add-task-dialog.component'; } @else {
@for (task of tasks(); track task.tasks.id) { - - - -
- assignment - {{ task.tasks.title }} -
-
- -
-
- By {{ getUserName(task.tasks.userId) }} - @if (getTaskComments(task.tasks.id).length) { - - comment - - } -
- {{ - formatDate(task.tasks.timestamp) - }} -
-
-
- -
-

- {{ task.tasks.description }} -

- - - -
-

- forum - Comments -

- - @if (getTaskComments(task.tasks.id).length) { -
- @for (comment of getTaskComments(task.tasks.id); track - comment.comments.id) { -
-
- {{ comment.users.name }} - {{ - formatDate(comment.comments.timestamp) - }} -
-

{{ comment.comments.content }}

-
- } -
- } @else { -

No comments yet.

- } - -
-

Add a comment

-
- - Your comment - - @if (commentForm.get('content')?.hasError('required') && - commentForm.get('content')?.touched) { - Comment text is required - } - -
- -
- - Post as - - @if (commentForm.get('userId')?.hasError('required') && - commentForm.get('userId')?.touched) { - User is required - } - - - -
-
-
-
-
+ }
} @@ -219,10 +84,7 @@ import { AddTaskDialogComponent } from './add-task-dialog.component'; position: fixed; bottom: 2rem; right: 2rem; - z-index: 1100; - display: flex; - align-items: center; - justify-content: center; + z-index: 100; } .page-container { @@ -231,7 +93,7 @@ import { AddTaskDialogComponent } from './add-task-dialog.component'; } .page-header { - margin-bottom: 2rem; + margin-bottom: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } @@ -243,9 +105,8 @@ import { AddTaskDialogComponent } from './add-task-dialog.component'; .loading-container { display: flex; flex-direction: column; - justify-content: center; align-items: center; - margin: 4rem 0; + margin: 3rem 0; gap: 1rem; } @@ -253,266 +114,30 @@ import { AddTaskDialogComponent } from './add-task-dialog.component'; display: flex; flex-direction: column; align-items: center; - justify-content: center; - background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); - border-radius: 12px; - padding: 4rem 2rem; - margin: 2rem 0; + background-color: #f5f7fa; + border-radius: 8px; + padding: 3rem 1.5rem; + margin: 1.5rem 0; text-align: center; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .empty-icon { - font-size: 4rem; - height: 4rem; - width: 4rem; + font-size: 3rem; + height: 3rem; + width: 3rem; color: #bdbdbd; 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: 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.75rem; - flex: 1; - min-width: 0; - margin-right: 1rem; - } - - .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 { - font-size: 0.8rem; - color: #757575; - } - - .task-details { - padding: 1.5rem 0; - } - - .task-description { - font-size: 1rem; - 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: 2rem; - } - - .comments-section h3 { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 1.2rem; - margin-bottom: 1.5rem; - color: #424242; - } - - .comments-section h3 mat-icon { - color: #1976d2; - } - - .comments-list { - margin-bottom: 2rem; - } - - .comment-item { - 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; - align-items: center; - margin-bottom: 0.75rem; - } - - .comment-author { - font-weight: 600; - color: #1976d2; - } - - .comment-time { - font-size: 0.8rem; - color: #757575; - } - - .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: 2rem; - background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%); - border-radius: 12px; - border: 2px dashed #e0e0e0; - } - - .comment-form { - 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: 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: 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 { - width: 100%; - } - - button { - height: 56px; - 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%; - } } `, ], @@ -521,7 +146,6 @@ 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); @@ -531,11 +155,6 @@ export class TaskListComponent { readonly loading = signal(true); readonly addingComment = signal(false); - readonly commentForm: FormGroup = this.fb.group({ - content: ['', Validators.required], - userId: ['', Validators.required], - }); - constructor() { this.loadData(); } @@ -584,55 +203,15 @@ export class TaskListComponent { }); } - 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.taskId === 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; + // Methods moved to child components + addComment(data: { taskId: number; content: string; userId: number }) { this.addingComment.set(true); const comment = { - content: this.commentForm.value.content, - taskId: taskId, - userId: parseInt(this.commentForm.value.userId), + content: data.content, + taskId: data.taskId, + userId: data.userId, }; this.commentsService @@ -641,7 +220,6 @@ export class TaskListComponent { .subscribe({ next: (newComment) => { this.comments.update((comments) => [...comments, newComment]); - this.commentForm.reset({ content: '', userId: '' }); this.toastService.show('Comment added successfully!'); }, error: (error) => { diff --git a/client/libs/web/users/src/index.ts b/client/libs/web/users/src/index.ts index 9363f14..cc46dec 100644 --- a/client/libs/web/users/src/index.ts +++ b/client/libs/web/users/src/index.ts @@ -1,2 +1,5 @@ +export * from './lib/add-user-dialog.component'; +export * from './lib/user-card.component'; export * from './lib/user-details.component'; export * from './lib/user-list.component'; +export * from './lib/user-task-list.component'; diff --git a/client/libs/web/users/src/lib/add-user-dialog.component.ts b/client/libs/web/users/src/lib/add-user-dialog.component.ts index 65dbd72..435ad79 100644 --- a/client/libs/web/users/src/lib/add-user-dialog.component.ts +++ b/client/libs/web/users/src/lib/add-user-dialog.component.ts @@ -1,7 +1,18 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +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 { + 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'; @@ -26,28 +37,25 @@ import { MatInputModule } from '@angular/material/input'; Name - @if (userForm.get('name')?.hasError('required') && userForm.get('name')?.touched) { - - Name is required - + @if (userForm.get('name')?.hasError('required') && + userForm.get('name')?.touched) { + Name is required } Email @if (userForm.get('email')?.hasError('required')) { - - Email is required - - } - @if (userForm.get('email')?.hasError('email') && !userForm.get('email')?.hasError('required')) { - - Please enter a valid email address - + Email is required + } @if (userForm.get('email')?.hasError('email') && + !userForm.get('email')?.hasError('required')) { + Please enter a valid email address } - + + + + {{ + user().name + }} + + {{ user().email }} + + +
+ + assignment + {{ taskCount() }} Tasks + + + comment + {{ commentCount() }} Comments + +
+
+ + `, + styles: [ + ` + .user-card { + position: relative; + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07); + transition: all 0.2s ease; + padding-bottom: 0.5rem; + overflow: hidden; + } + .user-card:hover { + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + transform: translateY(-3px); + } + .user-link { + text-decoration: none; + color: #1976d2; + font-weight: 600; + font-size: 1.1rem; + } + .user-link:hover { + text-decoration: underline; + } + .delete-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 2; + } + .user-meta { + display: flex; + gap: 1rem; + margin-top: 0.5rem; + font-size: 0.9rem; + color: #555; + } + .meta-icon { + vertical-align: middle; + margin-right: 0.2em; + color: #1976d2; + } + `, + ], +}) +export class UserCardComponent { + readonly user = input.required<{ id: number; name: string; email: string }>(); + readonly taskCount = input(0); + readonly commentCount = input(0); + readonly deleteUser = output(); +} diff --git a/client/libs/web/users/src/lib/user-details.component.ts b/client/libs/web/users/src/lib/user-details.component.ts index 4e969c6..8935ef7 100644 --- a/client/libs/web/users/src/lib/user-details.component.ts +++ b/client/libs/web/users/src/lib/user-details.component.ts @@ -9,7 +9,6 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatToolbarModule } from '@angular/material/toolbar'; import { ActivatedRoute, RouterModule } from '@angular/router'; @@ -19,7 +18,7 @@ import { User, UsersService, } from '@org/shared/api'; -import { TaskFormComponent } from '@org/web/tasks'; +import { UserTaskListComponent } from './user-task-list.component'; @Component({ selector: 'lib-user-details', @@ -28,11 +27,10 @@ import { TaskFormComponent } from '@org/web/tasks'; RouterModule, MatCardModule, MatButtonModule, - MatListModule, MatIconModule, MatProgressSpinnerModule, MatToolbarModule, - TaskFormComponent, + UserTaskListComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -75,43 +73,11 @@ import { TaskFormComponent } from '@org/web/tasks'; -
-

- - User Tasks -

- - @if (userTasks().length) { - - @for (task of userTasks(); track task.tasks.id) { - - -
-

{{ task.tasks.title }}

-
-
- {{ task.tasks.description || 'No description' }} -
-
- } -
- } @else { -
- -

No tasks found for this user.

-
- } - - -
+ } @else {
@@ -142,12 +108,6 @@ import { TaskFormComponent } from '@org/web/tasks'; padding: 0 1rem; } - @media (min-width: 576px) { - :host { - padding: 0 1.5rem; - } - } - .loading-container, .error-container { display: flex; @@ -157,13 +117,6 @@ 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; } @@ -176,14 +129,7 @@ import { TaskFormComponent } from '@org/web/tasks'; mat-toolbar { margin-bottom: 1.5rem; - border-radius: 4px; - } - - @media (min-width: 576px) { - mat-toolbar { - margin-bottom: 2rem; - border-radius: 8px; - } + border-radius: 8px; } .user-container { @@ -194,59 +140,15 @@ import { TaskFormComponent } from '@org/web/tasks'; @media (min-width: 768px) { .user-container { - gap: 2rem; grid-template-columns: 1fr 2fr; } } mat-card { border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + box-shadow: 0 2px 8px 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 { @@ -255,17 +157,10 @@ import { TaskFormComponent } from '@org/web/tasks'; 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; @@ -273,60 +168,19 @@ import { TaskFormComponent } from '@org/web/tasks'; 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; + border-left: 3px solid #3f51b5; } .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 { @@ -337,37 +191,17 @@ import { TaskFormComponent } from '@org/web/tasks'; 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; - } + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); } .back-button { margin-top: 1rem; - width: 100%; - } - - @media (min-width: 576px) { - .back-button { - width: auto; - } } `, ], diff --git a/client/libs/web/users/src/lib/user-list.component.ts b/client/libs/web/users/src/lib/user-list.component.ts index 133e57c..3844ffd 100644 --- a/client/libs/web/users/src/lib/user-list.component.ts +++ b/client/libs/web/users/src/lib/user-list.component.ts @@ -15,16 +15,11 @@ import { Validators, } 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'; 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 { RouterModule } from '@angular/router'; import { CommentResponse, TaskResponse, User } from '@org/shared/api'; import { CommentStoreService, @@ -33,6 +28,7 @@ import { } from '@org/shared/stores'; import { ToastService } from '@org/shared/toast'; import { AddUserDialogComponent } from './add-user-dialog.component'; +import { UserCardComponent } from './user-card.component'; type UserWithCounts = User & { taskCount: number; commentCount: number }; @@ -42,15 +38,11 @@ type UserWithCounts = User & { taskCount: number; commentCount: number }; CommonModule, MatFormFieldModule, MatInputModule, - MatListModule, - RouterModule, ReactiveFormsModule, MatProgressSpinnerModule, MatIconModule, - MatToolbarModule, MatButtonModule, - MatCardModule, - MatDividerModule, + UserCardComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -82,36 +74,12 @@ type UserWithCounts = User & { taskCount: number; commentCount: number }; } @else {
@for (user of filteredUsers(); track user.id) { - - - - - {{ - user.name - }} - - {{ user.email }} - - -
- - assignment - {{ user.taskCount }} Tasks - - - comment - {{ user.commentCount }} Comments - -
-
-
+ }
} @@ -139,64 +107,51 @@ type UserWithCounts = User & { taskCount: number; commentCount: number }; .empty-state { text-align: center; padding: 2rem; - background-color: var(--mat-grey-100, #f5f5f5); + background-color: #f5f5f5; border-radius: 4px; } .user-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; - margin-top: 2rem; + margin-top: 1.5rem; } .user-card { position: relative; - background: var(--mat-card-background, #fff); - border-radius: 18px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); - transition: box-shadow 0.2s, transform 0.2s; - will-change: box-shadow, transform; - animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1); + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + transition: all 0.2s ease; padding-bottom: 0.5rem; - overflow: visible; + overflow: hidden; } .user-card:hover { - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14); - transform: translateY(-4px) scale(1.02); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + transform: translateY(-3px); } .user-link { text-decoration: none; color: #1976d2; font-weight: 600; - font-size: 1.15rem; - transition: color 0.2s; + font-size: 1.1rem; } .user-link:hover { - color: #125ea2; text-decoration: underline; } .delete-btn { position: absolute; - top: 0.7rem; - right: 0.7rem; + top: 0.5rem; + right: 0.5rem; z-index: 2; - background: #fff; - border-radius: 50%; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07); - transition: background 0.2s; - } - .delete-btn:hover { - background: #ffeaea; } .user-meta { display: flex; - gap: 1.2rem; - margin-top: 0.7rem; - font-size: 0.98rem; + gap: 1rem; + margin-top: 0.5rem; + font-size: 0.9rem; color: #555; - align-items: center; } .meta-icon { - font-size: 1.1em; vertical-align: middle; margin-right: 0.2em; color: #1976d2; @@ -213,57 +168,6 @@ type UserWithCounts = User & { taskCount: number; commentCount: number }; flex-direction: column; gap: 0.5rem; } - .dialog-card { - min-width: 90vw; - } - } - .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: 1000; - animation: fadeIn 0.25s; - } - .dialog-card { - min-width: 320px; - max-width: 90vw; - border-radius: 18px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); - animation: scaleIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); - } - @keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(30px); - } - to { - opacity: 1; - transform: none; - } - } - @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - @keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } } `, ], diff --git a/client/libs/web/users/src/lib/user-task-list.component.ts b/client/libs/web/users/src/lib/user-task-list.component.ts new file mode 100644 index 0000000..293a906 --- /dev/null +++ b/client/libs/web/users/src/lib/user-task-list.component.ts @@ -0,0 +1,117 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { TaskResponse } from '@org/shared/api'; +import { TaskFormComponent } from '@org/web/tasks'; + +@Component({ + selector: 'lib-user-task-list', + imports: [CommonModule, MatIconModule, MatListModule, TaskFormComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

+ + User Tasks +

+ + @if (tasks().length) { + + @for (task of tasks(); track task.tasks.id) { + + +
+

{{ task.tasks.title }}

+
+
+ {{ task.tasks.description || 'No description' }} +
+
+ } +
+ } @else { +
+ +

No tasks found for this user.

+
+ } + + +
+ `, + styles: [ + ` + .tasks-section { + background-color: #f9f9f9; + border-radius: 8px; + padding: 1rem; + } + + .tasks-section h2 { + font-size: 1.25rem; + margin-top: 0; + margin-bottom: 1rem; + border-bottom: 1px solid #eee; + padding-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + mat-list { + border-radius: 4px; + overflow: hidden; + background-color: white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + } + + mat-list-item { + margin-bottom: 0.5rem; + border-left: 3px solid #3f51b5; + } + + .task-title { + margin: 0; + font-weight: 500; + } + + .task-description { + white-space: pre-line; + color: rgba(0, 0, 0, 0.6); + } + + .no-tasks { + text-align: center; + padding: 1.5rem; + color: rgba(0, 0, 0, 0.5); + background-color: white; + border-radius: 8px; + } + + lib-task-form { + display: block; + margin-top: 1.5rem; + padding: 1rem; + background-color: white; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + } + `, + ], +}) +export class UserTaskListComponent { + readonly tasks = input.required(); + readonly userId = input.required(); + readonly taskAdded = output(); +} diff --git a/server/src/db/local.db b/server/src/db/local.db index 001f315..313276e 100644 Binary files a/server/src/db/local.db and b/server/src/db/local.db differ