diff --git a/src/app/components/collaborator-item/collaborator-item.component.html b/src/app/components/collaborator-item/collaborator-item.component.html index 2712e00..2ef0207 100644 --- a/src/app/components/collaborator-item/collaborator-item.component.html +++ b/src/app/components/collaborator-item/collaborator-item.component.html @@ -1,6 +1,6 @@
- {{ user.name?.slice(0, 1) || '?' }} + {{ user.name.slice(0, 1) || '?' }}

{{ user.name }}

diff --git a/src/app/components/footer/footer.component.html b/src/app/components/footer/footer.component.html index 0f4ede7..13990d9 100644 --- a/src/app/components/footer/footer.component.html +++ b/src/app/components/footer/footer.component.html @@ -1,3 +1,5 @@

KanbanCloneAngular

+ AGPLv3 License Logo +
diff --git a/src/app/pages/collaborator-add/collaborator-add.component.css b/src/app/pages/collaborator-add/collaborator-add.component.css new file mode 100644 index 0000000..4989cf9 --- /dev/null +++ b/src/app/pages/collaborator-add/collaborator-add.component.css @@ -0,0 +1,84 @@ +.collaborator-add { + min-height: 100vh; + background-color: #f5f7fa; + padding: 40px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.card { + width: 100%; + max-width: 520px; + height: fit-content; + background: white; + border-radius: 12px; + padding: 32px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.card h1 { + margin: 0 0 24px 0; + font-size: 28px; + color: #2c3e50; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #374151; +} + +.form-group input { + width: 100%; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + font-size: 14px; + box-sizing: border-box; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 12px; +} + +.btn-primary { + padding: 10px 16px; + background-color: #10b981; + color: #fff; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-secondary { + padding: 10px 16px; + background: none; + border: 1px solid #cbd5f0; + color: #334155; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.error { + margin-bottom: 16px; + padding: 12px; + border-radius: 8px; + background-color: #fee2e2; + color: #b91c1c; +} diff --git a/src/app/pages/collaborator-add/collaborator-add.component.html b/src/app/pages/collaborator-add/collaborator-add.component.html new file mode 100644 index 0000000..9a71b1f --- /dev/null +++ b/src/app/pages/collaborator-add/collaborator-add.component.html @@ -0,0 +1,39 @@ +
+
+

Add collaborator

+ + @if (errorMessage()) { +
{{ errorMessage() }}
+ } + +
+
+ + +
+ +
+ + +
+
+
+
diff --git a/src/app/pages/collaborator-add/collaborator-add.component.ts b/src/app/pages/collaborator-add/collaborator-add.component.ts new file mode 100644 index 0000000..703fa1d --- /dev/null +++ b/src/app/pages/collaborator-add/collaborator-add.component.ts @@ -0,0 +1,76 @@ +import { Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApiService } from '../../services/api.service'; +import { AddCollaboratorRequest, AddCollaboratorResponse } from '../../models/projects.models'; + +@Component({ + selector: 'app-collaborator-add', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './collaborator-add.component.html', + styleUrl: './collaborator-add.component.css', +}) +export class CollaboratorAddComponent { + private apiService = inject(ApiService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + email = ''; + isSaving = signal(false); + errorMessage = signal(''); + private projectId: number | null = null; + + constructor() { + const idParam = this.route.snapshot.paramMap.get('id'); + const projectId = idParam ? Number(idParam) : Number.NaN; + + if (!Number.isFinite(projectId)) { + this.errorMessage.set('Invalid project id.'); + return; + } + + this.projectId = projectId; + } + + onSubmit() { + if (!this.email.trim()) { + this.errorMessage.set('Collaborator email is required.'); + return; + } + + if (this.projectId == null) { + this.errorMessage.set('Invalid project id.'); + return; + } + + const payload: AddCollaboratorRequest = { + user_email: this.email.trim(), + }; + + this.isSaving.set(true); + this.errorMessage.set(''); + + this.apiService + .post(`/projects/${this.projectId}/users/`, payload) + .subscribe({ + next: () => { + this.isSaving.set(false); + this.router.navigate(['/projects', this.projectId]); + }, + error: (error) => { + this.isSaving.set(false); + this.errorMessage.set(error?.error?.message || 'Failed to add collaborator.'); + }, + }); + } + + onCancel() { + if (this.projectId != null) { + this.router.navigate(['/projects', this.projectId]); + } else { + this.router.navigate(['/']); + } + } +} diff --git a/src/app/pages/project-details/project-details.component.css b/src/app/pages/project-details/project-details.component.css index c67b5fa..e53e872 100644 --- a/src/app/pages/project-details/project-details.component.css +++ b/src/app/pages/project-details/project-details.component.css @@ -1,35 +1,10 @@ -.details-container { - min-height: 100vh; - background-color: #f5f7fa; -} - .content { padding: 40px; max-width: 1200px; margin: 0 auto; -} - -.header { display: flex; - align-items: flex-start; - gap: 16px; - flex-wrap: wrap; - margin-bottom: 24px; -} - -.btn-back { - padding: 8px 16px; - border-radius: 8px; - border: none; - background-color: #e2e8f0; - color: #1f2933; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.btn-back:hover { - background-color: #cbd5e1; + flex-direction: column; + gap: 0.6rem; } .title h2 { @@ -50,18 +25,27 @@ .loading, .error-state, .empty-state { + height: fit-content; background: #ffffff; - padding: 24px; + padding: 1rem; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } -.project-card { - margin-bottom: 20px; +.project-card h2{ + margin: 0; + padding-top: 0.5rem; } -.collaborators-card { - margin-bottom: 20px; +.tasks-header, +.collaborators-header { + padding-top: .5rem; + padding-bottom: 1.5rem; +} + +.tasks-header h4, +.collaborators-header h4 { + margin: 0; } .danger-zone { @@ -144,12 +128,6 @@ background-color: #fee2e2; } -.project-card h3 { - margin: 0 0 12px 0; - font-size: 22px; - color: #111827; -} - .description { margin: 0; color: #4b5563; @@ -160,7 +138,6 @@ align-items: center; justify-content: space-between; gap: 16px; - margin-bottom: 16px; } .collaborators-header { @@ -168,7 +145,6 @@ align-items: center; justify-content: space-between; gap: 16px; - margin-bottom: 16px; } .collaborators-count { @@ -178,10 +154,14 @@ .collaborators-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.collaborators-grid--single { + grid-template-columns: minmax(0, 1fr); +} + .btn-add-task { width: 100%; padding: 8px 14px; @@ -221,10 +201,10 @@ color: #6b7280; } -.tasks-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; +.tasks-list { + display: flex; + flex-direction: column; + gap: 12px; } .loading p, diff --git a/src/app/pages/project-details/project-details.component.html b/src/app/pages/project-details/project-details.component.html index 7440746..c1097f2 100644 --- a/src/app/pages/project-details/project-details.component.html +++ b/src/app/pages/project-details/project-details.component.html @@ -17,6 +17,29 @@

+
+
+

Tasks

+ + {{ project()?.tasks?.length ?? 0 }} total • {{ taskCompletionPercentage }}% completed + +
+ + @if ((project()?.tasks?.length ?? 0) > 0) { +
+ @for (task of project()?.tasks ?? []; track task.id) { + + } +
+ } @else { +
+

No tasks yet.

+
+ } + + +
+

Collaborators

@@ -24,7 +47,10 @@
@if ((project()?.users?.length ?? 0) > 0) { -
+
@for (user of project()?.users ?? []; track user.id) { } @@ -35,28 +61,9 @@
} - -
- -
-
-

Tasks

- {{ project()?.tasks?.length ?? 0 }} total -
- - @if ((project()?.tasks?.length ?? 0) > 0) { -
- @for (task of project()?.tasks ?? []; track task.id) { - - } -
- } @else { -
-

No tasks yet.

-
- } - - +
@@ -70,14 +77,18 @@

Edit project details

Update the project name or description.

- +

Delete project

This will remove the project and all associated data.

- +
diff --git a/src/app/pages/project-details/project-details.component.ts b/src/app/pages/project-details/project-details.component.ts index 81d65d7..982469a 100644 --- a/src/app/pages/project-details/project-details.component.ts +++ b/src/app/pages/project-details/project-details.component.ts @@ -6,6 +6,7 @@ import { ProjectFull } from '../../models/projects.models'; import { CollaboratorItemComponent } from '../../components/collaborator-item/collaborator-item.component'; import { TaskItemComponent } from '../../components/task-item/task-item.component'; import { User } from '../../models/auth.models'; +import { Task } from '../../models/tasks.models'; @Component({ selector: 'app-project-details', @@ -24,6 +25,16 @@ export class ProjectDetailsComponent { isLoading = signal(true); errorMessage = signal(''); + get taskCompletionPercentage(): number { + const tasks = this.project()?.tasks ?? []; + if (tasks.length === 0) { + return 0; + } + + const completedCount = tasks.filter((task) => task.status === 'completed').length; + return Math.round((completedCount / tasks.length) * 100); + } + constructor() { const navState = this.router.getCurrentNavigation()?.extras.state ?? (history.state as any); const initialProject = navState?.project as ProjectFull | undefined; @@ -105,4 +116,74 @@ export class ProjectDetailsComponent { this.router.navigate(['/projects', this.projectId, 'tasks', 'new']); } + + onTaskStatusChange(event: { + task: Task; + status: Task['status']; + previousStatus: Task['status']; + }) { + if (this.projectId == null) { + return; + } + + this.apiService + .put(`/projects/${this.projectId}/tasks/${event.task.id}`, { + status: event.status, + }) + .subscribe({ + error: (error) => { + const current = this.project(); + if (!current) { + return; + } + + const updatedTasks = (current.tasks ?? []).map((task) => + task.id === event.task.id + ? { + ...task, + status: event.previousStatus, + } + : task, + ); + + this.project.set({ + ...current, + tasks: updatedTasks, + }); + + this.errorMessage.set(error?.error?.message || 'Failed to update task status.'); + }, + }); + } + + onAddCollaborator() { + if (this.projectId == null) { + return; + } + + this.router.navigate(['/projects', this.projectId, 'collaborators', 'new']); + } + + onDeleteProject() { + if (this.projectId == null) { + return; + } + + this.apiService.delete(`/projects/${this.projectId}`).subscribe({ + next: () => { + this.router.navigate(['/']); + }, + error: (error) => { + this.errorMessage.set(error?.error?.message || 'Failed to delete project.'); + }, + }); + } + + onEditProject() { + if (this.projectId == null) { + return; + } + + this.router.navigate(['/projects', this.projectId, 'edit']); + } } diff --git a/src/app/pages/project-edit/project-edit.component.css b/src/app/pages/project-edit/project-edit.component.css new file mode 100644 index 0000000..81fb488 --- /dev/null +++ b/src/app/pages/project-edit/project-edit.component.css @@ -0,0 +1,94 @@ +.edit-project { + min-height: 100vh; + background-color: #f5f7fa; + padding: 40px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.card { + width: 100%; + max-width: 640px; + height: fit-content; + background: white; + border-radius: 12px; + padding: 32px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.card h1 { + margin: 0 0 24px 0; + font-size: 28px; + color: #2c3e50; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #374151; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + font-size: 14px; + box-sizing: border-box; +} + +.form-group textarea { + resize: vertical; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 12px; +} + +.btn-primary { + padding: 10px 16px; + background-color: #10b981; + color: #fff; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-secondary { + padding: 10px 16px; + background: none; + border: 1px solid #cbd5f0; + color: #334155; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.error { + margin-bottom: 16px; + padding: 12px; + border-radius: 8px; + background-color: #fee2e2; + color: #b91c1c; +} + +.loading { + margin: 0; + color: #6b7280; +} diff --git a/src/app/pages/project-edit/project-edit.component.html b/src/app/pages/project-edit/project-edit.component.html new file mode 100644 index 0000000..057284d --- /dev/null +++ b/src/app/pages/project-edit/project-edit.component.html @@ -0,0 +1,54 @@ +
+
+

Edit project

+ + @if (errorMessage()) { +
{{ errorMessage() }}
+ } + + @if (isLoading()) { +

Loading project...

+ } @else { +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ } +
+
diff --git a/src/app/pages/project-edit/project-edit.component.ts b/src/app/pages/project-edit/project-edit.component.ts new file mode 100644 index 0000000..d76320f --- /dev/null +++ b/src/app/pages/project-edit/project-edit.component.ts @@ -0,0 +1,95 @@ +import { Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApiService } from '../../services/api.service'; +import { Project, UpdateProjectRequest } from '../../models/projects.models'; + +@Component({ + selector: 'app-project-edit', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './project-edit.component.html', + styleUrl: './project-edit.component.css', +}) +export class ProjectEditComponent { + private apiService = inject(ApiService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + name = ''; + description = ''; + isSaving = signal(false); + isLoading = signal(true); + errorMessage = signal(''); + private projectId: number | null = null; + + constructor() { + const idParam = this.route.snapshot.paramMap.get('id'); + const projectId = idParam ? Number(idParam) : Number.NaN; + + if (!Number.isFinite(projectId)) { + this.isLoading.set(false); + this.errorMessage.set('Invalid project id.'); + return; + } + + this.projectId = projectId; + + this.apiService.get(`/projects/${projectId}`).subscribe({ + next: (project) => { + this.name = project.name ?? ''; + this.description = project.description ?? ''; + this.isLoading.set(false); + }, + error: (error) => { + this.isLoading.set(false); + this.errorMessage.set(error?.error?.message || 'Failed to load project.'); + }, + }); + } + + onSubmit() { + if (!this.name.trim()) { + this.errorMessage.set('Project name is required.'); + return; + } + + if (!this.description.trim()) { + this.errorMessage.set('Project description is required.'); + return; + } + + if (this.projectId == null) { + this.errorMessage.set('Invalid project id.'); + return; + } + + const payload: UpdateProjectRequest = { + name: this.name.trim(), + description: this.description.trim(), + }; + + this.isSaving.set(true); + this.errorMessage.set(''); + + this.apiService.put(`/projects/${this.projectId}`, payload).subscribe({ + next: () => { + this.isSaving.set(false); + this.router.navigate(['/projects', this.projectId]); + }, + error: (error) => { + this.isSaving.set(false); + this.errorMessage.set(error?.error?.message || 'Failed to update project.'); + }, + }); + } + + onCancel() { + if (this.projectId != null) { + this.router.navigate(['/projects', this.projectId]); + } else { + this.router.navigate(['/']); + } + } +}