minor changes /j

This commit is contained in:
Marta Borgia Leiva 2026-02-10 13:51:55 +01:00
parent f7f12356de
commit 377c8b6146
11 changed files with 587 additions and 71 deletions

View file

@ -1,6 +1,6 @@
<article class="collaborator-item">
<div class="collaborator-avatar">
{{ user.name?.slice(0, 1) || '?' }}
{{ user.name.slice(0, 1) || '?' }}
</div>
<div class="collaborator-info">
<h4>{{ user.name }}</h4>

View file

@ -1,3 +1,5 @@
<footer class="footer">
<p>KanbanCloneAngular</p>
<a href="https://www.gnu.org/licenses/agpl-3.0.html"><img src="https://www.gnu.org/graphics/agplv3-88x31.png" alt="AGPLv3 License Logo"></a>
<a href="https://github.com/a-mayb3/KanbanCloneAngular"><img src="" alt=""></a>
</footer>

View file

@ -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;
}

View file

@ -0,0 +1,39 @@
<div class="collaborator-add">
<div class="card">
<h1>Add collaborator</h1>
@if (errorMessage()) {
<div class="error">{{ errorMessage() }}</div>
}
<form (ngSubmit)="onSubmit()" #collaboratorForm="ngForm">
<div class="form-group">
<label for="email">Collaborator e-mail</label>
<input
id="email"
type="email"
name="email"
[(ngModel)]="email"
placeholder="Enter collaborator e-mail"
required
autocomplete="email"
/>
</div>
<div class="actions">
<button type="button" class="btn-secondary" (click)="onCancel()">Cancel</button>
<button
type="submit"
class="btn-primary"
[disabled]="isSaving() || !collaboratorForm.form.valid"
>
@if (isSaving()) {
<span>Adding...</span>
} @else {
<span>Add collaborator</span>
}
</button>
</div>
</form>
</div>
</div>

View file

@ -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<AddCollaboratorResponse>(`/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(['/']);
}
}
}

View file

@ -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,

View file

@ -17,6 +17,29 @@
</p>
</section>
<section class="tasks-card">
<div class="tasks-header">
<h4>Tasks</h4>
<span class="tasks-count">
{{ project()?.tasks?.length ?? 0 }} total • {{ taskCompletionPercentage }}% completed
</span>
</div>
@if ((project()?.tasks?.length ?? 0) > 0) {
<div class="tasks-list">
@for (task of project()?.tasks ?? []; track task.id) {
<app-task-item [task]="task" (statusChange)="onTaskStatusChange($event)" />
}
</div>
} @else {
<div class="empty-state">
<p>No tasks yet.</p>
</div>
}
<button class="btn-add-task" type="button" (click)="onAddTask()">Add task</button>
</section>
<section class="collaborators-card">
<div class="collaborators-header">
<h4>Collaborators</h4>
@ -24,7 +47,10 @@
</div>
@if ((project()?.users?.length ?? 0) > 0) {
<div class="collaborators-grid">
<div
class="collaborators-grid"
[class.collaborators-grid--single]="(project()?.users?.length ?? 0) === 1"
>
@for (user of project()?.users ?? []; track user.id) {
<app-collaborator-item [user]="user" (remove)="onRemoveCollaborator($event)" />
}
@ -35,28 +61,9 @@
</div>
}
<button class="btn-add-collaborator" type="button">Add collaborator</button>
</section>
<section class="tasks-card">
<div class="tasks-header">
<h4>Tasks</h4>
<span class="tasks-count">{{ project()?.tasks?.length ?? 0 }} total</span>
</div>
@if ((project()?.tasks?.length ?? 0) > 0) {
<div class="tasks-grid">
@for (task of project()?.tasks ?? []; track task.id) {
<app-task-item [task]="task" />
}
</div>
} @else {
<div class="empty-state">
<p>No tasks yet.</p>
</div>
}
<button class="btn-add-task" type="button" (click)="onAddTask()">Add task</button>
<button class="btn-add-collaborator" type="button" (click)="onAddCollaborator()">
Add collaborator
</button>
</section>
<section class="danger-zone">
@ -70,14 +77,18 @@
<h4>Edit project details</h4>
<p>Update the project name or description.</p>
</div>
<button class="btn-danger-outline" type="button">Edit project</button>
<button class="btn-danger-outline" type="button" (click)="onEditProject()">
Edit project
</button>
</div>
<div class="danger-item">
<div>
<h4>Delete project</h4>
<p>This will remove the project and all associated data.</p>
</div>
<button class="btn-danger" type="button">Delete project</button>
<button class="btn-danger" type="button" (click)="onDeleteProject()">
Delete project
</button>
</div>
</div>
</section>

View file

@ -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<void>(`/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<void>(`/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']);
}
}

View file

@ -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;
}

View file

@ -0,0 +1,54 @@
<div class="edit-project">
<div class="card">
<h1>Edit project</h1>
@if (errorMessage()) {
<div class="error">{{ errorMessage() }}</div>
}
@if (isLoading()) {
<p class="loading">Loading project...</p>
} @else {
<form (ngSubmit)="onSubmit()" #projectForm="ngForm">
<div class="form-group">
<label for="name">Project name</label>
<input
id="name"
type="text"
name="name"
[(ngModel)]="name"
placeholder="Enter a project name"
required
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
[(ngModel)]="description"
rows="4"
placeholder="Enter a project description"
required
></textarea>
</div>
<div class="actions">
<button type="button" class="btn-secondary" (click)="onCancel()">Cancel</button>
<button
type="submit"
class="btn-primary"
[disabled]="isSaving() || !projectForm.form.valid"
>
@if (isSaving()) {
<span>Saving...</span>
} @else {
<span>Save changes</span>
}
</button>
</div>
</form>
}
</div>
</div>

View file

@ -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<Project>(`/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<Project>(`/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(['/']);
}
}
}