feat: add Angular application source code with task management feature
This commit is contained in:
parent
fede1a6c6f
commit
7d9c4acc7a
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
provideRouter,
|
||||
withComponentInputBinding,
|
||||
withViewTransitions,
|
||||
} from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { errorInterceptor, loadingInterceptor, apiResponseInterceptor } from '@core/index';
|
||||
import { TASK_DATA_SOURCE } from './features/tasks/data-access/data-sources/task-data-source.token';
|
||||
import { HttpTaskDataSource } from './features/tasks/data-access/data-sources/http-task.data-source';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
|
||||
provideHttpClient(withInterceptors([
|
||||
apiResponseInterceptor,
|
||||
errorInterceptor,
|
||||
loadingInterceptor,
|
||||
])),
|
||||
provideAnimationsAsync(),
|
||||
{
|
||||
provide: TASK_DATA_SOURCE,
|
||||
useClass: HttpTaskDataSource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<main id="main" class="app">
|
||||
<router-outlet />
|
||||
<emi-toast />
|
||||
</main>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Routes } from '@angular/router';
|
||||
import { NotFound } from '@shared/index';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'tasks',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
loadChildren: () =>
|
||||
import('./features/tasks/tasks.routes').then(m => m.TASK_ROUTES),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
component: NotFound,
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Component, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { Toast } from '@shared/ui/toast/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-root',
|
||||
imports: [RouterOutlet, Toast],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('task-challenge');
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { CanDeactivateFn } from '@angular/router';
|
||||
|
||||
export interface CanComponentDeactivate {
|
||||
canDeactivate: () => boolean;
|
||||
}
|
||||
|
||||
export const unsavedChangesGuard: CanDeactivateFn<CanComponentDeactivate> = (component) => {
|
||||
return component.canDeactivate ? component.canDeactivate() : true;
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export { provideCore } from './providers';
|
||||
|
||||
export { errorInterceptor } from './interceptors/error-interceptor';
|
||||
export { loadingInterceptor } from './interceptors/loading-interceptor';
|
||||
export { apiResponseInterceptor } from './interceptors/api-response-interceptor';
|
||||
|
||||
export { Notification } from './services/notification';
|
||||
export { Loading } from './services/loading';
|
||||
|
||||
export { unsavedChangesGuard } from './guards/unsaved-changes-guard';
|
||||
export type { CanComponentDeactivate } from './guards/unsaved-changes-guard';
|
||||
|
||||
export type { ApiError } from './models/api-error.model';
|
||||
export type { Pagination, PaginatedResponse, PaginationParams } from './models/pagination.model';
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const apiResponseInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req).pipe(
|
||||
map(event => {
|
||||
if (event instanceof HttpResponse && event.body) {
|
||||
const body = event.body as ApiResponse<unknown>;
|
||||
|
||||
if ('success' in body && 'data' in body) {
|
||||
return event.clone({ body: body.data });
|
||||
}
|
||||
}
|
||||
return event;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { Notification } from '../services/notification';
|
||||
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const notification = inject(Notification);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
let message = 'An unexpected error occurred';
|
||||
|
||||
if (error.status === 0) {
|
||||
message = 'Unable to connect to the server. Please check your connection.';
|
||||
} else if (error.status === 404) {
|
||||
message = 'The requested resource was not found.';
|
||||
} else if (error.status === 400) {
|
||||
message = error.error?.error || error.error?.message || 'Invalid request. Please check your input.';
|
||||
} else if (error.status === 401) {
|
||||
message = 'You are not authorized to perform this action.';
|
||||
} else if (error.status === 403) {
|
||||
message = 'You do not have permission to perform this action.';
|
||||
} else if (error.status === 500) {
|
||||
message = 'A server error occurred. Please try again later.';
|
||||
} else if (error.error?.error || error.error?.message) {
|
||||
message = error.error.error || error.error.message;
|
||||
}
|
||||
|
||||
notification.error(message);
|
||||
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
|
||||
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req);
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export interface ApiError {
|
||||
status: number;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
path?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export interface Pagination {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { type EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
|
||||
|
||||
export function provideCore(): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([]);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Loading {}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
export interface NotificationMessage {
|
||||
id: number;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Notification {
|
||||
private readonly messages = signal<NotificationMessage[]>([]);
|
||||
private nextId = 0;
|
||||
|
||||
readonly notifications = this.messages.asReadonly();
|
||||
|
||||
show(type: NotificationMessage['type'], message: string): void {
|
||||
const id = this.nextId++;
|
||||
this.messages.update(msgs => [...msgs, { id, type, message }]);
|
||||
|
||||
setTimeout(() => this.dismiss(id), 5000);
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
this.show('success', message);
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.show('error', message);
|
||||
}
|
||||
|
||||
warning(message: string): void {
|
||||
this.show('warning', message);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.show('info', message);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.messages.update(msgs => msgs.filter(m => m.id !== id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { TaskDataSource } from './task-data-source.interface';
|
||||
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
|
||||
import { PaginatedResponse, PaginationParams } from '@core/index';
|
||||
import { environment } from '@environments/environment';
|
||||
|
||||
@Injectable()
|
||||
export class HttpTaskDataSource implements TaskDataSource {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/tasks`;
|
||||
|
||||
getAll(_params?: PaginationParams): Observable<PaginatedResponse<Task>> {
|
||||
return this.http.get<Task[]>(this.baseUrl).pipe(
|
||||
map(tasks => ({
|
||||
data: tasks,
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: tasks.length,
|
||||
total: tasks.length,
|
||||
totalPages: 1,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Task> {
|
||||
return this.http.get<Task>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(dto: CreateTaskDto): Observable<Task> {
|
||||
return this.http.post<Task>(this.baseUrl, dto);
|
||||
}
|
||||
|
||||
update(id: string, dto: UpdateTaskDto): Observable<Task> {
|
||||
return this.http.put<Task>(`${this.baseUrl}/${id}`, dto);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
transition(id: string, state: TaskState): Observable<Task> {
|
||||
return this.http.patch<Task>(`${this.baseUrl}/${id}/transition`, { state });
|
||||
}
|
||||
|
||||
addNote(taskId: string, note: string): Observable<Task> {
|
||||
return this.http.post<Task>(`${this.baseUrl}/${taskId}/notes`, { note });
|
||||
}
|
||||
|
||||
deleteNote(taskId: string, noteIndex: number): Observable<Task> {
|
||||
return this.http.delete<Task>(`${this.baseUrl}/${taskId}/notes/${noteIndex}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Observable } from 'rxjs';
|
||||
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
|
||||
import { PaginatedResponse, PaginationParams } from '@core/index';
|
||||
|
||||
export interface TaskDataSource {
|
||||
getAll(params?: PaginationParams): Observable<PaginatedResponse<Task>>;
|
||||
getById(id: string): Observable<Task>;
|
||||
create(dto: CreateTaskDto): Observable<Task>;
|
||||
update(id: string, dto: UpdateTaskDto): Observable<Task>;
|
||||
delete(id: string): Observable<void>;
|
||||
transition(id: string, state: TaskState): Observable<Task>;
|
||||
addNote(taskId: string, note: string): Observable<Task>;
|
||||
deleteNote(taskId: string, noteIndex: number): Observable<Task>;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { InjectionToken } from '@angular/core';
|
||||
import { TaskDataSource } from './task-data-source.interface';
|
||||
|
||||
export const TASK_DATA_SOURCE = new InjectionToken<TaskDataSource>('TaskDataSource');
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export { TaskService } from './services/task';
|
||||
export { TaskStore } from './store/task-store';
|
||||
|
||||
export { taskResolver } from './resolvers/task-resolver';
|
||||
|
||||
export type { Task, TaskState, StateHistoryEntry, CreateTaskDto, UpdateTaskDto } from './models/task.model';
|
||||
export { createTask } from './models/task.model';
|
||||
export type { TaskFilter, TaskSort, TaskListState } from './models/task-state.model';
|
||||
export { defaultFilter, defaultSort } from './models/task-state.model';
|
||||
export type { TaskDto } from './models/task-dto.model';
|
||||
|
||||
export type { TaskDataSource } from './data-sources/task-data-source.interface';
|
||||
export { TASK_DATA_SOURCE } from './data-sources/task-data-source.token';
|
||||
export { HttpTaskDataSource } from './data-sources/http-task.data-source';
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export interface TaskDto {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dueDate: string;
|
||||
stateHistory: { state: string; date: string }[];
|
||||
notes: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Task, TaskState } from './task.model';
|
||||
|
||||
export interface TaskFilter {
|
||||
state: TaskState | 'all';
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface TaskSort {
|
||||
field: keyof Task;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface TaskListState {
|
||||
tasks: Task[];
|
||||
filter: TaskFilter;
|
||||
sort: TaskSort;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const defaultFilter: TaskFilter = {
|
||||
state: 'all',
|
||||
search: '',
|
||||
};
|
||||
|
||||
export const defaultSort: TaskSort = {
|
||||
field: 'dueDate',
|
||||
direction: 'asc',
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export type TaskState = 'new' | 'active' | 'resolved' | 'closed';
|
||||
|
||||
export interface StateHistoryEntry {
|
||||
state: TaskState;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dueDate: string;
|
||||
stateHistory: StateHistoryEntry[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface CreateTaskDto {
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
initialNote?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTaskDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
state?: TaskState;
|
||||
}
|
||||
|
||||
export function createTask(dto: CreateTaskDto): Task {
|
||||
const now = new Date().toISOString().split('T')[0];
|
||||
return {
|
||||
id: uuid(),
|
||||
title: dto.title,
|
||||
description: dto.description ?? '',
|
||||
dueDate: dto.dueDate ?? now,
|
||||
stateHistory: [{ state: 'new', date: now }],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { ResolveFn } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { TaskStore } from '../store/task-store';
|
||||
import { Task } from '../models/task.model';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TaskService } from '../services/task';
|
||||
|
||||
export const taskResolver: ResolveFn<Task | null> = async (route) => {
|
||||
const service = inject(TaskService);
|
||||
const store = inject(TaskStore);
|
||||
const id = route.paramMap.get('id');
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await firstValueFrom(service.getById(id));
|
||||
store.setSelectedTask(task);
|
||||
return task;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { TaskDataSource } from '../data-sources/task-data-source.interface';
|
||||
import { TASK_DATA_SOURCE } from '../data-sources/task-data-source.token';
|
||||
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
|
||||
import { PaginatedResponse, PaginationParams } from '@core/index';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TaskService {
|
||||
private readonly dataSource = inject<TaskDataSource>(TASK_DATA_SOURCE);
|
||||
|
||||
getAll(params?: PaginationParams): Observable<PaginatedResponse<Task>> {
|
||||
return this.dataSource.getAll(params);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Task> {
|
||||
return this.dataSource.getById(id);
|
||||
}
|
||||
|
||||
create(dto: CreateTaskDto): Observable<Task> {
|
||||
return this.dataSource.create(dto);
|
||||
}
|
||||
|
||||
update(id: string, dto: UpdateTaskDto): Observable<Task> {
|
||||
return this.dataSource.update(id, dto);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.dataSource.delete(id);
|
||||
}
|
||||
|
||||
transition(id: string, state: TaskState): Observable<Task> {
|
||||
return this.dataSource.transition(id, state);
|
||||
}
|
||||
|
||||
addNote(taskId: string, note: string): Observable<Task> {
|
||||
return this.dataSource.addNote(taskId, note);
|
||||
}
|
||||
|
||||
deleteNote(taskId: string, noteIndex: number): Observable<Task> {
|
||||
return this.dataSource.deleteNote(taskId, noteIndex);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { TaskService } from '../services/task';
|
||||
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
|
||||
import { TaskFilter, TaskSort, defaultFilter, defaultSort } from '../models/task-state.model';
|
||||
import { PaginatedResponse, PaginationParams } from '@core/index';
|
||||
import { Notification } from '@core/services/notification';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TaskStore {
|
||||
private readonly taskService = inject(TaskService);
|
||||
private readonly notification = inject(Notification);
|
||||
|
||||
private readonly tasksSignal = signal<Task[]>([]);
|
||||
private readonly selectedTaskSignal = signal<Task | null>(null);
|
||||
private readonly filterSignal = signal<TaskFilter>(defaultFilter);
|
||||
private readonly sortSignal = signal<TaskSort>(defaultSort);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
private readonly paginationSignal = signal<PaginatedResponse<Task>['pagination'] | null>(null);
|
||||
private readonly transitioningStateSignal = signal<TaskState | null>(null);
|
||||
|
||||
readonly tasks = this.tasksSignal.asReadonly();
|
||||
readonly selectedTask = this.selectedTaskSignal.asReadonly();
|
||||
readonly filter = this.filterSignal.asReadonly();
|
||||
readonly sort = this.sortSignal.asReadonly();
|
||||
readonly loading = this.loadingSignal.asReadonly();
|
||||
readonly error = this.errorSignal.asReadonly();
|
||||
readonly pagination = this.paginationSignal.asReadonly();
|
||||
readonly transitioningState = this.transitioningStateSignal.asReadonly();
|
||||
|
||||
readonly filteredTasks = computed(() => {
|
||||
let result = this.tasksSignal();
|
||||
const filter = this.filterSignal();
|
||||
|
||||
if (filter.state !== 'all') {
|
||||
result = result.filter(t => {
|
||||
const lastState = t.stateHistory[t.stateHistory.length - 1];
|
||||
return lastState?.state === filter.state;
|
||||
});
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
result = result.filter(t =>
|
||||
t.title.toLowerCase().includes(search) ||
|
||||
t.description.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
loadTasks(params?: PaginationParams): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.errorSignal.set(null);
|
||||
|
||||
this.taskService.getAll(params).subscribe({
|
||||
next: (response) => {
|
||||
this.tasksSignal.set(response.data);
|
||||
this.paginationSignal.set(response.pagination);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
this.errorSignal.set(error.message);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadTask(id: string): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.errorSignal.set(null);
|
||||
|
||||
this.taskService.getById(id).subscribe({
|
||||
next: (task) => {
|
||||
this.selectedTaskSignal.set(task);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
this.errorSignal.set(error.message);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedTask(task: Task | null): void {
|
||||
this.selectedTaskSignal.set(task);
|
||||
}
|
||||
|
||||
createTask(dto: CreateTaskDto): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.errorSignal.set(null);
|
||||
|
||||
this.taskService.create(dto).subscribe({
|
||||
next: (task) => {
|
||||
if (dto.initialNote?.trim()) {
|
||||
this.taskService.addNote(task.id, dto.initialNote).subscribe({
|
||||
next: (updated) => {
|
||||
this.tasksSignal.update(tasks => [...tasks, updated]);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.tasksSignal.update(tasks => [...tasks, task]);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.tasksSignal.update(tasks => [...tasks, task]);
|
||||
this.loadingSignal.set(false);
|
||||
}
|
||||
},
|
||||
error: (error: Error) => {
|
||||
this.errorSignal.set(error.message);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateTask(id: string, dto: UpdateTaskDto): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.errorSignal.set(null);
|
||||
|
||||
this.taskService.update(id, dto).subscribe({
|
||||
next: (updated) => {
|
||||
this.tasksSignal.update(tasks =>
|
||||
tasks.map(t => t.id === id ? updated : t)
|
||||
);
|
||||
if (this.selectedTaskSignal()?.id === id) {
|
||||
this.selectedTaskSignal.set(updated);
|
||||
}
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
this.errorSignal.set(error.message);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteTask(id: string): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.errorSignal.set(null);
|
||||
|
||||
this.taskService.delete(id).subscribe({
|
||||
next: () => {
|
||||
this.tasksSignal.update(tasks => tasks.filter(t => t.id !== id));
|
||||
if (this.selectedTaskSignal()?.id === id) {
|
||||
this.selectedTaskSignal.set(null);
|
||||
}
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
this.errorSignal.set(error.message);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
transitionTask(id: string, state: TaskState): void {
|
||||
const currentTask = this.tasksSignal().find(t => t.id === id);
|
||||
if (!currentTask) return;
|
||||
|
||||
const currentState = currentTask.stateHistory[currentTask.stateHistory.length - 1]?.state;
|
||||
if (currentState === state) return;
|
||||
|
||||
this.transitioningStateSignal.set(state);
|
||||
|
||||
const optimisticTask: Task = {
|
||||
...currentTask,
|
||||
stateHistory: [
|
||||
...currentTask.stateHistory,
|
||||
{ state, date: new Date().toISOString().split('T')[0] },
|
||||
],
|
||||
};
|
||||
|
||||
this.tasksSignal.update(tasks =>
|
||||
tasks.map(t => t.id === id ? optimisticTask : t)
|
||||
);
|
||||
if (this.selectedTaskSignal()?.id === id) {
|
||||
this.selectedTaskSignal.set(optimisticTask);
|
||||
}
|
||||
|
||||
this.taskService.transition(id, state).subscribe({
|
||||
next: (updated) => {
|
||||
this.tasksSignal.update(tasks =>
|
||||
tasks.map(t => t.id === id ? updated : t)
|
||||
);
|
||||
if (this.selectedTaskSignal()?.id === id) {
|
||||
this.selectedTaskSignal.set(updated);
|
||||
}
|
||||
this.transitioningStateSignal.set(null);
|
||||
this.notification.success(`Task moved to ${state}`);
|
||||
},
|
||||
error: () => {
|
||||
this.tasksSignal.update(tasks =>
|
||||
tasks.map(t => t.id === id ? currentTask : t)
|
||||
);
|
||||
if (this.selectedTaskSignal()?.id === id) {
|
||||
this.selectedTaskSignal.set(currentTask);
|
||||
}
|
||||
this.transitioningStateSignal.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
addNote(taskId: string, note: string): void {
|
||||
this.taskService.addNote(taskId, note).subscribe({
|
||||
next: (updated) => {
|
||||
this.tasksSignal.update(tasks =>
|
||||
tasks.map(t => t.id === taskId ? updated : t)
|
||||
);
|
||||
if (this.selectedTaskSignal()?.id === taskId) {
|
||||
this.selectedTaskSignal.set(updated);
|
||||
}
|
||||
},
|
||||
error: (error: Error) => {
|
||||
this.errorSignal.set(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteNote(taskId: string, noteIndex: number): void {
|
||||
this.taskService.deleteNote(taskId, noteIndex).subscribe({
|
||||
next: (updated) => {
|
||||
this.tasksSignal.update(tasks =>
|
||||
tasks.map(t => t.id === taskId ? updated : t)
|
||||
);
|
||||
if (this.selectedTaskSignal()?.id === taskId) {
|
||||
this.selectedTaskSignal.set(updated);
|
||||
}
|
||||
},
|
||||
error: (error: Error) => {
|
||||
this.errorSignal.set(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateFilter(filter: Partial<TaskFilter>): void {
|
||||
this.filterSignal.update(f => ({ ...f, ...filter }));
|
||||
}
|
||||
|
||||
updateSort(sort: TaskSort): void {
|
||||
this.sortSignal.set(sort);
|
||||
}
|
||||
|
||||
clearError(): void {
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.tasksSignal.set([]);
|
||||
this.selectedTaskSignal.set(null);
|
||||
this.filterSignal.set(defaultFilter);
|
||||
this.sortSignal.set(defaultSort);
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(null);
|
||||
this.paginationSignal.set(null);
|
||||
this.transitioningStateSignal.set(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
.task-create-page {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-create-page__header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.task-create-page__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-create-page__error {
|
||||
background-color: rgba(220, 38, 38, 0.1);
|
||||
color: var(--color-danger);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-6);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.task-create-page__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.task-create-page__form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.task-create-page__label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-create-page__input,
|
||||
.task-create-page__textarea {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--fs-base);
|
||||
font-family: var(--font-body);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast);
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-create-page__textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.task-create-page__input:focus,
|
||||
.task-create-page__textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(230, 57, 70, 0.15);
|
||||
}
|
||||
|
||||
.task-create-page__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.task-create-page {
|
||||
padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<section class="task-create-page" aria-label="Create new task">
|
||||
<header class="task-create-page__header">
|
||||
<h1 class="task-create-page__title">Create Task</h1>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="task-create-page__error" role="alert" aria-live="polite">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="task-create-page__form"
|
||||
[formGroup]="form"
|
||||
(ngSubmit)="onSubmit()"
|
||||
aria-label="Task form"
|
||||
>
|
||||
<div class="task-create-page__form-field">
|
||||
<label class="task-create-page__label" for="title">
|
||||
Title <span aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
</label>
|
||||
<input
|
||||
class="task-create-page__input"
|
||||
id="title"
|
||||
formControlName="title"
|
||||
type="text"
|
||||
placeholder="Enter task title"
|
||||
aria-required="true"
|
||||
aria-describedby="title-hint"
|
||||
/>
|
||||
<span id="title-hint" class="sr-only">Enter a descriptive title for the task</span>
|
||||
</div>
|
||||
|
||||
<div class="task-create-page__form-field">
|
||||
<label class="task-create-page__label" for="description">Description</label>
|
||||
<textarea
|
||||
class="task-create-page__textarea"
|
||||
id="description"
|
||||
formControlName="description"
|
||||
placeholder="Enter task description"
|
||||
aria-describedby="desc-hint"
|
||||
></textarea>
|
||||
<span id="desc-hint" class="sr-only">Optional: provide additional details about the task</span>
|
||||
</div>
|
||||
|
||||
<div class="task-create-page__form-field">
|
||||
<label class="task-create-page__label" for="dueDate">Due Date</label>
|
||||
<input
|
||||
class="task-create-page__input"
|
||||
id="dueDate"
|
||||
formControlName="dueDate"
|
||||
type="date"
|
||||
aria-describedby="date-hint"
|
||||
/>
|
||||
<span id="date-hint" class="sr-only">Select when this task should be completed</span>
|
||||
</div>
|
||||
|
||||
<div class="task-create-page__form-field">
|
||||
<label class="task-create-page__label" for="initialNote">
|
||||
Initial Note <span aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="task-create-page__textarea"
|
||||
id="initialNote"
|
||||
formControlName="initialNote"
|
||||
placeholder="Add an initial note for this task"
|
||||
aria-required="true"
|
||||
aria-describedby="note-hint"
|
||||
></textarea>
|
||||
<span id="note-hint" class="sr-only">Required: add at least one note to the task</span>
|
||||
</div>
|
||||
|
||||
<div class="task-create-page__actions" role="group" aria-label="Form actions">
|
||||
<emi-button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
(clicked)="onCancel()"
|
||||
aria-label="Cancel and go back to task list"
|
||||
>
|
||||
Cancel
|
||||
</emi-button>
|
||||
<emi-button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
[disabled]="loading() || form.invalid"
|
||||
aria-label="Create new task"
|
||||
>
|
||||
{{ loading() ? 'Creating...' : 'Create Task' }}
|
||||
</emi-button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NonNullableFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { TaskStore } from '../../data-access/store/task-store';
|
||||
import { CreateTaskDto } from '../../data-access/models/task.model';
|
||||
import { trimmedRequiredValidator } from '@shared/index';
|
||||
import { Button } from '@shared/index';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-create-page',
|
||||
imports: [ReactiveFormsModule, Button],
|
||||
templateUrl: './task-create-page.html',
|
||||
styleUrl: './task-create-page.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskCreatePage {
|
||||
private readonly store = inject(TaskStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
|
||||
readonly form = this.fb.group({
|
||||
title: ['', [trimmedRequiredValidator, Validators.maxLength(100)]],
|
||||
description: ['', Validators.maxLength(500)],
|
||||
dueDate: this.fb.control<string>(new Date().toISOString().split('T')[0]),
|
||||
initialNote: ['', [trimmedRequiredValidator, Validators.maxLength(500)]],
|
||||
});
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.valid) {
|
||||
const dto: CreateTaskDto = this.form.getRawValue();
|
||||
this.store.createTask(dto);
|
||||
this.router.navigate(['/tasks']);
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.router.navigate(['/tasks']);
|
||||
}
|
||||
|
||||
canDeactivate(): boolean {
|
||||
return this.form.pristine;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<section class="task-detail">
|
||||
@if (loading()) {
|
||||
<p>Loading...</p>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<p class="error">{{ error() }}</p>
|
||||
}
|
||||
|
||||
@if (task(); as task) {
|
||||
<header class="task-detail__header">
|
||||
<button (click)="onBack()">Back</button>
|
||||
<h1>{{ task.title }}</h1>
|
||||
<emi-task-state-badge [state]="currentState ?? 'new'" />
|
||||
</header>
|
||||
|
||||
<div class="task-detail__info">
|
||||
<p><strong>Description:</strong> {{ task.description }}</p>
|
||||
<p><strong>Due Date:</strong> {{ task.dueDate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="task-detail__states">
|
||||
<h3>State History</h3>
|
||||
<ul>
|
||||
@for (entry of task.stateHistory; track entry.date) {
|
||||
<li>{{ entry.state }} - {{ entry.date }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="task-detail__notes">
|
||||
<h3>Notes</h3>
|
||||
<ul>
|
||||
@for (note of task.notes; track $index) {
|
||||
<li>
|
||||
{{ note }}
|
||||
<button (click)="onDeleteNote($index)">Delete</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="task-detail__actions">
|
||||
<button (click)="onDeleteTask()">Delete Task</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TaskStore } from '../../data-access/store/task-store';
|
||||
import { UpdateTaskDto, TaskState } from '../../data-access/models/task.model';
|
||||
import { TaskStateBadge } from '../../ui/task-state-badge/task-state-badge';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-detail-page',
|
||||
imports: [TaskStateBadge],
|
||||
templateUrl: './task-detail-page.html',
|
||||
styleUrl: './task-detail-page.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskDetailPage implements OnInit {
|
||||
private readonly store = inject(TaskStore);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly task = this.store.selectedTask;
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
this.store.loadTask(id);
|
||||
}
|
||||
}
|
||||
|
||||
get currentState(): TaskState | null {
|
||||
const task = this.task();
|
||||
if (!task || task.stateHistory.length === 0) return null;
|
||||
return task.stateHistory[task.stateHistory.length - 1].state;
|
||||
}
|
||||
|
||||
onUpdateTask(dto: UpdateTaskDto): void {
|
||||
const task = this.task();
|
||||
if (task) {
|
||||
this.store.updateTask(task.id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
onChangeState(state: TaskState): void {
|
||||
const task = this.task();
|
||||
if (task) {
|
||||
this.store.transitionTask(task.id, state);
|
||||
}
|
||||
}
|
||||
|
||||
onDeleteTask(): void {
|
||||
const task = this.task();
|
||||
if (task) {
|
||||
this.store.deleteTask(task.id);
|
||||
this.router.navigate(['/tasks']);
|
||||
}
|
||||
}
|
||||
|
||||
onAddNote(content: string): void {
|
||||
const task = this.task();
|
||||
if (task) {
|
||||
this.store.addNote(task.id, content);
|
||||
}
|
||||
}
|
||||
|
||||
onDeleteNote(noteIndex: number): void {
|
||||
const task = this.task();
|
||||
if (task) {
|
||||
this.store.deleteNote(task.id, noteIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
this.router.navigate(['/tasks']);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,534 @@
|
|||
.task-list {
|
||||
padding: var(--space-8) var(--space-10);
|
||||
background-color: var(--bg-surface);
|
||||
transition: padding-right var(--transition-base);
|
||||
}
|
||||
|
||||
.task-list--sidebar-open {
|
||||
padding-right: calc(var(--space-10) + 420px);
|
||||
}
|
||||
|
||||
.task-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.task-list__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.task-list__title {
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-bold);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-list__status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
background-color: var(--color-success);
|
||||
color: var(--color-white);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.task-list__status-pill::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.task-list__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.task-list__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 38px;
|
||||
padding: 0 var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-list__btn:hover {
|
||||
background: var(--bg-page);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.task-list__btn--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-white);
|
||||
border-color: var(--color-brand-primary);
|
||||
font-weight: var(--fw-semibold);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.task-list__btn--primary:hover {
|
||||
background: var(--color-brand-primary-hover);
|
||||
border-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
.task-list__tabs {
|
||||
display: flex;
|
||||
gap: var(--space-10);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.task-list__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.task-list__tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-list__tab--active {
|
||||
color: var(--color-brand-primary);
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.task-list__progress {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.task-list__progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.task-list__progress-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-list__progress-value {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.task-list__progress-track {
|
||||
height: 6px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-list__progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-brand-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.task-list__loading,
|
||||
.task-list__error {
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-list__error {
|
||||
color: var(--state-error);
|
||||
}
|
||||
|
||||
.task-list__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.task-list__th {
|
||||
text-align: left;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.task-list__td {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.task-list__td--task {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.task-list__row {
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.task-list__row:hover {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.task-list__row--highlight {
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
|
||||
.task-list__row--completed .task-list__task-title,
|
||||
.task-list__row--completed .task-list__task-subtitle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-list__progress-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.task-list__progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-list__progress-bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-success);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.task-list__progress-percent {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-secondary);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.task-list__task-title {
|
||||
display: block;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--lh-snug);
|
||||
}
|
||||
|
||||
.task-list__task-subtitle {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.task-list__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.task-list__badge--active {
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
color: var(--color-success-dark);
|
||||
}
|
||||
|
||||
.task-list__badge--new {
|
||||
background: rgba(234, 88, 12, 0.12);
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.task-list__badge--resolved {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.task-list__badge--closed {
|
||||
background: var(--color-gray-100);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-list__due-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-list__due-date svg {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-list__notes {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-list__empty {
|
||||
text-align: center;
|
||||
padding: var(--space-10);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-list__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-1);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-list__legend {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.task-list__legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.task-list__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.task-list__dot--completed {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.task-list__dot--active {
|
||||
background: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.task-list__dot--new {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.task-list__total {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-list__board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-list__timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.task-list__timeline-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.task-list__timeline-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.task-list__timeline-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--color-brand-primary);
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-list__timeline-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-list__timeline-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-left: calc(12px + var(--space-3));
|
||||
border-left: 2px solid var(--border-subtle);
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.task-list__timeline-card {
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-list__timeline-card:hover {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.task-list__timeline-card--finalized {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.task-list__timeline-card--finalized .task-list__task-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-list__timeline-card-header,
|
||||
.task-list__timeline-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.task-list__timeline-card-header {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.task-list__timeline-card-footer {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.task-list__timeline-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-left: calc(12px + var(--space-3));
|
||||
border-left: 2px solid var(--border-subtle);
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.task-list__timeline-card {
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-list__timeline-card:hover {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.task-list__timeline-card--finalized {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.task-list__timeline-card--finalized .task-list__task-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-list__timeline-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.task-list__timeline-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-list {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.task-list--sidebar-open {
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
.task-list__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-list__table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-list__td {
|
||||
display: block;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.task-list__row {
|
||||
display: block;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.task-list__footer {
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-list__board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-list__timeline-card-header,
|
||||
.task-list__timeline-card-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.task-list__logo {
|
||||
width: 100px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
<section class="task-list" [class.task-list--sidebar-open]="selectedTask() || showCreateSidebar()">
|
||||
<header class="task-list__header">
|
||||
<div class="task-list__title-row">
|
||||
<img src="/images/logo.png" alt="Logo" class="task-list__logo">
|
||||
<h1 class="task-list__title">Task Board</h1>
|
||||
</div>
|
||||
<div class="task-list__actions">
|
||||
<button class="task-list__btn task-list__btn--primary" (click)="onOpenCreateSidebar()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="task-list__tabs">
|
||||
<button
|
||||
class="task-list__tab"
|
||||
[class.task-list__tab--active]="activeView() === 'table'"
|
||||
(click)="onViewChange('table')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
class="task-list__tab"
|
||||
[class.task-list__tab--active]="activeView() === 'board'"
|
||||
(click)="onViewChange('board')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="11" rx="1"/></svg>
|
||||
Board
|
||||
</button>
|
||||
<button
|
||||
class="task-list__tab"
|
||||
[class.task-list__tab--active]="activeView() === 'timeline'"
|
||||
(click)="onViewChange('timeline')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
Timeline
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="task-list__progress">
|
||||
<div class="task-list__progress-header">
|
||||
<span class="task-list__progress-label">Sprint Progress</span>
|
||||
<span class="task-list__progress-value">{{ completionPercent() }}% complete</span>
|
||||
</div>
|
||||
<div class="task-list__progress-track">
|
||||
<div class="task-list__progress-fill" [style.width.%]="completionPercent()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="task-list__loading">
|
||||
<p>Loading tasks...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading()) {
|
||||
@switch (activeView()) {
|
||||
@case ('table') {
|
||||
<table class="task-list__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="task-list__th">Task</th>
|
||||
<th class="task-list__th">Progress</th>
|
||||
<th class="task-list__th">Status</th>
|
||||
<th class="task-list__th">Due Date</th>
|
||||
<th class="task-list__th">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (task of paginatedTasks(); track task.id) {
|
||||
<tr
|
||||
class="task-list__row"
|
||||
[class.task-list__row--completed]="isFinalized(task)"
|
||||
[class.task-list__row--highlight]="isSelected(task)"
|
||||
(click)="onViewTask(task)"
|
||||
>
|
||||
<td class="task-list__td task-list__td--task">
|
||||
<span class="task-list__task-title">{{ task.title }}</span>
|
||||
<span class="task-list__task-subtitle">{{ task.description }}</span>
|
||||
</td>
|
||||
<td class="task-list__td">
|
||||
<div class="task-list__progress-cell">
|
||||
<div class="task-list__progress-bar">
|
||||
<div
|
||||
class="task-list__progress-bar-fill"
|
||||
[style.width.%]="getStateProgress(task)"
|
||||
></div>
|
||||
</div>
|
||||
<span class="task-list__progress-percent">{{ getStateProgress(task) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="task-list__td">
|
||||
<span class="task-list__badge task-list__badge--{{ getState(task) }}">
|
||||
{{ getStateLabel(task) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="task-list__td">
|
||||
<span class="task-list__due-date">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{{ task.dueDate }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="task-list__td">
|
||||
<span class="task-list__notes">{{ task.notes.length }} {{ task.notes.length === 1 ? 'note' : 'notes' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="task-list__empty">No tasks found</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (tasks().length > PAGE_SIZE) {
|
||||
<emi-pagination
|
||||
[currentPage]="currentPage()"
|
||||
[totalPages]="totalPages()"
|
||||
[totalItems]="tasks().length"
|
||||
[pageSize]="PAGE_SIZE"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<footer class="task-list__footer">
|
||||
<div class="task-list__legend">
|
||||
<span class="task-list__legend-item">
|
||||
<span class="task-list__dot task-list__dot--completed"></span>
|
||||
{{ completedCount() }} Completed
|
||||
</span>
|
||||
<span class="task-list__legend-item">
|
||||
<span class="task-list__dot task-list__dot--active"></span>
|
||||
{{ activeCount() }} Active
|
||||
</span>
|
||||
<span class="task-list__legend-item">
|
||||
<span class="task-list__dot task-list__dot--new"></span>
|
||||
{{ newCount() }} New
|
||||
</span>
|
||||
</div>
|
||||
<span class="task-list__total">{{ tasks().length }} tasks total</span>
|
||||
</footer>
|
||||
}
|
||||
|
||||
@case ('board') {
|
||||
<div class="task-list__board">
|
||||
<emi-task-board-column
|
||||
state="new"
|
||||
title="New"
|
||||
[tasks]="tasksByState()['new']"
|
||||
(view)="onBoardViewTask($event)"
|
||||
(edit)="onBoardEditTask($event)"
|
||||
(delete)="onBoardDeleteTask($event)"
|
||||
/>
|
||||
<emi-task-board-column
|
||||
state="active"
|
||||
title="Active"
|
||||
[tasks]="tasksByState()['active']"
|
||||
(view)="onBoardViewTask($event)"
|
||||
(edit)="onBoardEditTask($event)"
|
||||
(delete)="onBoardDeleteTask($event)"
|
||||
/>
|
||||
<emi-task-board-column
|
||||
state="resolved"
|
||||
title="Resolved"
|
||||
[tasks]="tasksByState()['resolved']"
|
||||
(view)="onBoardViewTask($event)"
|
||||
(edit)="onBoardEditTask($event)"
|
||||
(delete)="onBoardDeleteTask($event)"
|
||||
/>
|
||||
<emi-task-board-column
|
||||
state="closed"
|
||||
title="Closed"
|
||||
[tasks]="tasksByState()['closed']"
|
||||
(view)="onBoardViewTask($event)"
|
||||
(edit)="onBoardEditTask($event)"
|
||||
(delete)="onBoardDeleteTask($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('timeline') {
|
||||
<div class="task-list__timeline">
|
||||
@for (date of dueDates(); track date) {
|
||||
<div class="task-list__timeline-group">
|
||||
<div class="task-list__timeline-date">
|
||||
<span class="task-list__timeline-dot"></span>
|
||||
<span class="task-list__timeline-label">{{ date }}</span>
|
||||
</div>
|
||||
<div class="task-list__timeline-items">
|
||||
@for (task of tasksByDate()[date]; track task.id) {
|
||||
<div
|
||||
class="task-list__timeline-card"
|
||||
[class.task-list__timeline-card--finalized]="isFinalized(task)"
|
||||
(click)="onViewTask(task)"
|
||||
>
|
||||
<div class="task-list__timeline-card-header">
|
||||
<span class="task-list__task-title">{{ task.title }}</span>
|
||||
<span class="task-list__badge task-list__badge--{{ getState(task) }}">
|
||||
{{ getStateLabel(task) }}
|
||||
</span>
|
||||
</div>
|
||||
@if (task.description) {
|
||||
<p class="task-list__task-subtitle">{{ task.description }}</p>
|
||||
}
|
||||
<div class="task-list__timeline-card-footer">
|
||||
<div class="task-list__progress-cell">
|
||||
<div class="task-list__progress-bar">
|
||||
<div
|
||||
class="task-list__progress-bar-fill"
|
||||
[style.width.%]="getStateProgress(task)"
|
||||
></div>
|
||||
</div>
|
||||
<span class="task-list__progress-percent">{{ getStateProgress(task) }}%</span>
|
||||
</div>
|
||||
<span class="task-list__notes">{{ task.notes.length }} {{ task.notes.length === 1 ? 'note' : 'notes' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="task-list__empty">No tasks found</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (selectedTask(); as task) {
|
||||
<emi-task-detail-sidebar
|
||||
[task]="task"
|
||||
[transitioningState]="transitioningState()"
|
||||
(close)="onCloseDetailSidebar()"
|
||||
(changeState)="onChangeState($event)"
|
||||
(deleteTask)="onDeleteTask(task)"
|
||||
(addNote)="onAddNote($event)"
|
||||
(deleteNote)="onDeleteNote($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (showCreateSidebar()) {
|
||||
<emi-task-create-sidebar
|
||||
(close)="onCloseCreateSidebar()"
|
||||
(created)="onTaskCreated()"
|
||||
/>
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { afterNextRender, ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { TaskStore } from '../../data-access/store/task-store';
|
||||
import { Task, TaskState } from '../../data-access/models/task.model';
|
||||
import { TaskDetailSidebar } from '../../ui/task-detail-sidebar/task-detail-sidebar';
|
||||
import { TaskCreateSidebar } from '../../ui/task-create-sidebar/task-create-sidebar';
|
||||
import { TaskBoardColumn } from '../../ui/task-board-column/task-board-column';
|
||||
import { Pagination } from '@shared/ui/pagination/pagination';
|
||||
|
||||
export type ViewMode = 'table' | 'board' | 'timeline';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-list-page',
|
||||
imports: [TaskDetailSidebar, TaskCreateSidebar, TaskBoardColumn, Pagination],
|
||||
templateUrl: './task-list-page.html',
|
||||
styleUrl: './task-list-page.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskListPage {
|
||||
private readonly store = inject(TaskStore);
|
||||
|
||||
readonly PAGE_SIZE = 5;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.store.loadTasks();
|
||||
});
|
||||
}
|
||||
|
||||
readonly tasks = this.store.filteredTasks;
|
||||
readonly loading = this.store.loading;
|
||||
readonly selectedTask = this.store.selectedTask;
|
||||
readonly transitioningState = this.store.transitioningState;
|
||||
readonly showCreateSidebar = signal(false);
|
||||
readonly activeView = signal<ViewMode>('table');
|
||||
readonly currentPage = signal(1);
|
||||
|
||||
private readonly stateProgress: Record<TaskState, number> = {
|
||||
new: 0,
|
||||
active: 50,
|
||||
resolved: 100,
|
||||
closed: 100,
|
||||
};
|
||||
|
||||
private readonly stateLabels: Record<TaskState, string> = {
|
||||
new: 'New',
|
||||
active: 'Active',
|
||||
resolved: 'Resolved',
|
||||
closed: 'Closed',
|
||||
};
|
||||
|
||||
readonly totalPages = computed(() => {
|
||||
return Math.max(1, Math.ceil(this.tasks().length / this.PAGE_SIZE));
|
||||
});
|
||||
|
||||
readonly paginatedTasks = computed(() => {
|
||||
const start = (this.currentPage() - 1) * this.PAGE_SIZE;
|
||||
return this.tasks().slice(start, start + this.PAGE_SIZE);
|
||||
});
|
||||
|
||||
readonly completionPercent = computed(() => {
|
||||
const all = this.tasks();
|
||||
if (all.length === 0) return 0;
|
||||
const totalProgress = all.reduce((sum, t) => sum + this.getStateProgress(t), 0);
|
||||
return Math.round(totalProgress / all.length);
|
||||
});
|
||||
|
||||
readonly completedCount = computed(() =>
|
||||
this.tasks().filter(t => this.isFinalized(t)).length
|
||||
);
|
||||
|
||||
readonly activeCount = computed(() =>
|
||||
this.tasks().filter(t => this.getState(t) === 'active').length
|
||||
);
|
||||
|
||||
readonly newCount = computed(() =>
|
||||
this.tasks().filter(t => this.getState(t) === 'new').length
|
||||
);
|
||||
|
||||
readonly tasksByState = computed(() => {
|
||||
const grouped: Record<TaskState, Task[]> = {
|
||||
new: [],
|
||||
active: [],
|
||||
resolved: [],
|
||||
closed: [],
|
||||
};
|
||||
for (const task of this.tasks()) {
|
||||
const state = this.getState(task);
|
||||
grouped[state].push(task);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
|
||||
readonly tasksByDate = computed(() => {
|
||||
const sorted = [...this.tasks()].sort((a, b) =>
|
||||
new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
|
||||
);
|
||||
const grouped: Record<string, Task[]> = {};
|
||||
for (const task of sorted) {
|
||||
const date = task.dueDate;
|
||||
if (!grouped[date]) {
|
||||
grouped[date] = [];
|
||||
}
|
||||
grouped[date].push(task);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
|
||||
readonly dueDates = computed(() => Object.keys(this.tasksByDate()));
|
||||
|
||||
onPageChange(page: number): void {
|
||||
this.currentPage.set(page);
|
||||
}
|
||||
|
||||
onViewChange(view: ViewMode): void {
|
||||
this.activeView.set(view);
|
||||
}
|
||||
|
||||
getState(task: Task): TaskState {
|
||||
const history = task.stateHistory;
|
||||
return history[history.length - 1]?.state ?? 'new';
|
||||
}
|
||||
|
||||
getStateLabel(task: Task): string {
|
||||
return this.stateLabels[this.getState(task)];
|
||||
}
|
||||
|
||||
getStateProgress(task: Task): number {
|
||||
return this.stateProgress[this.getState(task)];
|
||||
}
|
||||
|
||||
isFinalized(task: Task): boolean {
|
||||
const state = this.getState(task);
|
||||
return state === 'resolved' || state === 'closed';
|
||||
}
|
||||
|
||||
isSelected(task: Task): boolean {
|
||||
return this.selectedTask()?.id === task.id;
|
||||
}
|
||||
|
||||
onViewTask(task: Task): void {
|
||||
this.showCreateSidebar.set(false);
|
||||
this.store.setSelectedTask(task);
|
||||
}
|
||||
|
||||
onCloseDetailSidebar(): void {
|
||||
this.store.setSelectedTask(null);
|
||||
}
|
||||
|
||||
onChangeState(state: TaskState): void {
|
||||
const task = this.selectedTask();
|
||||
if (task) {
|
||||
this.store.transitionTask(task.id, state);
|
||||
}
|
||||
}
|
||||
|
||||
onDeleteTask(task: Task): void {
|
||||
this.store.deleteTask(task.id);
|
||||
this.store.setSelectedTask(null);
|
||||
}
|
||||
|
||||
onAddNote(content: string): void {
|
||||
const task = this.selectedTask();
|
||||
if (task) {
|
||||
this.store.addNote(task.id, content);
|
||||
}
|
||||
}
|
||||
|
||||
onDeleteNote(index: number): void {
|
||||
const task = this.selectedTask();
|
||||
if (task) {
|
||||
this.store.deleteNote(task.id, index);
|
||||
}
|
||||
}
|
||||
|
||||
onOpenCreateSidebar(): void {
|
||||
this.store.setSelectedTask(null);
|
||||
this.showCreateSidebar.set(true);
|
||||
}
|
||||
|
||||
onCloseCreateSidebar(): void {
|
||||
this.showCreateSidebar.set(false);
|
||||
}
|
||||
|
||||
onTaskCreated(): void {
|
||||
this.showCreateSidebar.set(false);
|
||||
}
|
||||
|
||||
onBoardViewTask(task: Task): void {
|
||||
this.onViewTask(task);
|
||||
}
|
||||
|
||||
onBoardEditTask(task: Task): void {
|
||||
this.onViewTask(task);
|
||||
}
|
||||
|
||||
onBoardDeleteTask(task: Task): void {
|
||||
this.onDeleteTask(task);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Routes } from '@angular/router';
|
||||
import { taskResolver } from './data-access/resolvers/task-resolver';
|
||||
import { unsavedChangesGuard } from '@core/index';
|
||||
|
||||
export const TASK_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./feature/task-list-page/task-list-page').then(m => m.TaskListPage),
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
loadComponent: () =>
|
||||
import('./feature/task-create-page/task-create-page').then(m => m.TaskCreatePage),
|
||||
canDeactivate: [unsavedChangesGuard],
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
loadComponent: () =>
|
||||
import('./feature/task-detail-page/task-detail-page').then(m => m.TaskDetailPage),
|
||||
resolve: { task: taskResolver },
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
.board-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-page);
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 272px;
|
||||
max-height: calc(100vh - 180px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.board-column__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.board-column__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-column__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-column--new .board-column__dot {
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.board-column--active .board-column__dot {
|
||||
background-color: var(--color-info);
|
||||
}
|
||||
|
||||
.board-column--resolved .board-column__dot {
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.board-column--closed .board-column__dot {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.board-column__title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.board-column__count {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-muted);
|
||||
background-color: var(--color-gray-200);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.board-column__add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.board-column__add-btn:hover {
|
||||
background: var(--color-gray-200);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.board-column__add-btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.board-column__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-column__card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.board-column__card:hover {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--border-default);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.board-column__card-header {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.board-column__card-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--lh-snug);
|
||||
}
|
||||
|
||||
.board-column__card-desc {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--space-2);
|
||||
line-height: var(--lh-normal);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board-column__card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.board-column__card-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.board-column__card-notes {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.board-column__empty {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<div class="board-column" [class]="stateClass">
|
||||
<header class="board-column__header">
|
||||
<div class="board-column__header-left">
|
||||
<span class="board-column__dot"></span>
|
||||
<h3 class="board-column__title">{{ title() }}</h3>
|
||||
<span class="board-column__count">{{ tasks().length }}</span>
|
||||
</div>
|
||||
<button class="board-column__add-btn" (click)="onAdd()" aria-label="Add task">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="board-column__body">
|
||||
@for (task of tasks(); track task.id) {
|
||||
<div class="board-column__card" (click)="onView(task)">
|
||||
<div class="board-column__card-header">
|
||||
<span class="board-column__card-title">{{ task.title }}</span>
|
||||
</div>
|
||||
@if (task.description) {
|
||||
<p class="board-column__card-desc">{{ task.description }}</p>
|
||||
}
|
||||
<div class="board-column__card-footer">
|
||||
<span class="board-column__card-date">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{{ task.dueDate }}
|
||||
</span>
|
||||
<span class="board-column__card-notes">{{ task.notes.length }} {{ task.notes.length === 1 ? 'note' : 'notes' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="board-column__empty">No tasks</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { Task, TaskState } from '../../data-access/models/task.model';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-board-column',
|
||||
imports: [],
|
||||
templateUrl: './task-board-column.html',
|
||||
styleUrl: './task-board-column.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskBoardColumn {
|
||||
state = input.required<TaskState>();
|
||||
title = input.required<string>();
|
||||
tasks = input.required<Task[]>();
|
||||
|
||||
view = output<Task>();
|
||||
edit = output<Task>();
|
||||
delete = output<Task>();
|
||||
add = output<void>();
|
||||
|
||||
get stateClass(): string {
|
||||
return `board-column--${this.state()}`;
|
||||
}
|
||||
|
||||
onView(task: Task): void {
|
||||
this.view.emit(task);
|
||||
}
|
||||
|
||||
onEdit(task: Task): void {
|
||||
this.edit.emit(task);
|
||||
}
|
||||
|
||||
onDelete(task: Task): void {
|
||||
this.delete.emit(task);
|
||||
}
|
||||
|
||||
onAdd(): void {
|
||||
this.add.emit();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<article class="task-card">
|
||||
<header class="task-card__header">
|
||||
<h3>{{ task().title }}</h3>
|
||||
<emi-task-state-badge [state]="currentState" />
|
||||
</header>
|
||||
|
||||
<p class="task-card__description">{{ task().description }}</p>
|
||||
|
||||
<footer class="task-card__footer">
|
||||
<span class="task-card__date">Due: {{ task().dueDate }}</span>
|
||||
</footer>
|
||||
|
||||
<div class="task-card__actions">
|
||||
<button (click)="onView()">View</button>
|
||||
<button (click)="onEdit()">Edit</button>
|
||||
<button (click)="onDelete()">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { Task, TaskState } from '../../data-access/models/task.model';
|
||||
import { TaskStateBadge } from '../task-state-badge/task-state-badge';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-card',
|
||||
imports: [TaskStateBadge],
|
||||
templateUrl: './task-card.html',
|
||||
styleUrl: './task-card.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskCard {
|
||||
task = input.required<Task>();
|
||||
view = output<Task>();
|
||||
edit = output<Task>();
|
||||
delete = output<Task>();
|
||||
|
||||
get currentState(): TaskState {
|
||||
const history = this.task().stateHistory;
|
||||
return history[history.length - 1]?.state ?? 'new';
|
||||
}
|
||||
|
||||
onView(): void {
|
||||
this.view.emit(this.task());
|
||||
}
|
||||
|
||||
onEdit(): void {
|
||||
this.edit.emit(this.task());
|
||||
}
|
||||
|
||||
onDelete(): void {
|
||||
this.delete.emit(this.task());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-modal);
|
||||
animation: slideIn var(--transition-base) ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar__close:hover {
|
||||
background-color: var(--color-gray-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar__close:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar__form {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.sidebar__error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background-color: rgba(220, 38, 38, 0.1);
|
||||
color: var(--state-error);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.sidebar__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar__label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar__input,
|
||||
.sidebar__textarea {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--fs-base);
|
||||
font-family: var(--font-body);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.sidebar__input:focus,
|
||||
.sidebar__textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(230, 57, 70, 0.15);
|
||||
}
|
||||
|
||||
.sidebar__textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.sidebar__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar__btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--fs-base);
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--fw-semibold);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.sidebar__btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar__btn--primary {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-white);
|
||||
box-shadow: var(--shadow-brand);
|
||||
}
|
||||
|
||||
.sidebar__btn--primary:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sidebar__btn--primary:active:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-active);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sidebar__btn--primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar__btn--ghost {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.sidebar__btn--ghost:hover:not(:disabled) {
|
||||
background-color: var(--color-gray-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<aside class="sidebar">
|
||||
<header class="sidebar__header">
|
||||
<h2 class="sidebar__title">New Task</h2>
|
||||
<button class="sidebar__close" (click)="onClose()" aria-label="Close sidebar">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="sidebar__form" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
@if (error()) {
|
||||
<div class="sidebar__error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<div class="sidebar__field">
|
||||
<label class="sidebar__label" for="create-title">Title *</label>
|
||||
<input
|
||||
class="sidebar__input"
|
||||
id="create-title"
|
||||
formControlName="title"
|
||||
type="text"
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar__field">
|
||||
<label class="sidebar__label" for="create-description">Description</label>
|
||||
<textarea
|
||||
class="sidebar__textarea"
|
||||
id="create-description"
|
||||
formControlName="description"
|
||||
placeholder="Enter task description"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="sidebar__field">
|
||||
<label class="sidebar__label" for="create-dueDate">Due Date</label>
|
||||
<input
|
||||
class="sidebar__input"
|
||||
id="create-dueDate"
|
||||
formControlName="dueDate"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar__field">
|
||||
<label class="sidebar__label" for="create-initialNote">Initial Note *</label>
|
||||
<textarea
|
||||
class="sidebar__textarea"
|
||||
id="create-initialNote"
|
||||
formControlName="initialNote"
|
||||
placeholder="Add an initial note for this task"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="sidebar__actions">
|
||||
<button type="button" class="sidebar__btn sidebar__btn--ghost" (click)="onClose()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="sidebar__btn sidebar__btn--primary"
|
||||
[disabled]="loading() || form.invalid"
|
||||
>
|
||||
{{ loading() ? 'Creating...' : 'Create Task' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core';
|
||||
import { NonNullableFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { TaskStore } from '../../data-access/store/task-store';
|
||||
import { CreateTaskDto } from '../../data-access/models/task.model';
|
||||
import { trimmedRequiredValidator } from '@shared/index';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-create-sidebar',
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './task-create-sidebar.html',
|
||||
styleUrl: './task-create-sidebar.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskCreateSidebar {
|
||||
private readonly store = inject(TaskStore);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
|
||||
close = output<void>();
|
||||
created = output<void>();
|
||||
|
||||
readonly form = this.fb.group({
|
||||
title: ['', [trimmedRequiredValidator, Validators.maxLength(100)]],
|
||||
description: ['', Validators.maxLength(500)],
|
||||
dueDate: this.fb.control<string>(new Date().toISOString().split('T')[0]),
|
||||
initialNote: ['', [trimmedRequiredValidator, Validators.maxLength(500)]],
|
||||
});
|
||||
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.valid) {
|
||||
const dto: CreateTaskDto = this.form.getRawValue();
|
||||
this.store.createTask(dto);
|
||||
this.created.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
.task-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 420px;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-modal);
|
||||
animation: task-sidebar-slide-in var(--transition-base) var(--ease-out);
|
||||
}
|
||||
|
||||
@keyframes task-sidebar-slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.task-sidebar {
|
||||
animation-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
.task-sidebar__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.task-sidebar__title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--lh-snug);
|
||||
}
|
||||
|
||||
.task-sidebar__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-sidebar__close:hover {
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-sidebar__close:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.task-sidebar__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.task-sidebar__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.task-sidebar__label {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-sidebar__text {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.task-sidebar__text--muted {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.task-sidebar__meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.task-sidebar__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.task-sidebar__meta-value {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.task-sidebar__history {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.task-sidebar__history-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-page);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.task-sidebar__history-state {
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: capitalize;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-sidebar__history-date {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.task-sidebar__footer {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.task-sidebar__states {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
text-transform: capitalize;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn:hover {
|
||||
background: var(--bg-page);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn--active {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-white);
|
||||
border-color: var(--color-brand-primary);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn--active:hover {
|
||||
background: var(--color-brand-primary-hover);
|
||||
border-color: var(--color-brand-primary-hover);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn--disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn--loading {
|
||||
opacity: 1;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn-text--hidden {
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-sidebar__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.task-sidebar__action-btn {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.task-sidebar__action-btn:hover {
|
||||
background: var(--bg-page);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.task-sidebar__action-btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.task-sidebar__action-btn--danger {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.task-sidebar__action-btn--danger:hover {
|
||||
background: var(--color-danger);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.task-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-sidebar__meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-sidebar__states {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-sidebar__state-btn {
|
||||
flex: 1 1 calc(50% - var(--space-1));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<aside class="task-sidebar">
|
||||
<header class="task-sidebar__header">
|
||||
<h2 class="task-sidebar__title">{{ task().title }}</h2>
|
||||
<button class="task-sidebar__close" (click)="onClose()" aria-label="Close sidebar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="task-sidebar__body">
|
||||
<div class="task-sidebar__section">
|
||||
<emi-task-state-badge [state]="currentState()" />
|
||||
</div>
|
||||
|
||||
<div class="task-sidebar__section">
|
||||
<h3 class="task-sidebar__label">Description</h3>
|
||||
<p class="task-sidebar__text" [class.task-sidebar__text--muted]="!task().description">
|
||||
{{ task().description || 'No description provided' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="task-sidebar__meta">
|
||||
<div class="task-sidebar__meta-item">
|
||||
<h3 class="task-sidebar__label">Due Date</h3>
|
||||
<span class="task-sidebar__meta-value">{{ task().dueDate }}</span>
|
||||
</div>
|
||||
<div class="task-sidebar__meta-item">
|
||||
<h3 class="task-sidebar__label">Progress</h3>
|
||||
<span class="task-sidebar__meta-value">{{ getStateProgress() }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-sidebar__section">
|
||||
<h3 class="task-sidebar__label">State History</h3>
|
||||
<ul class="task-sidebar__history">
|
||||
@for (entry of task().stateHistory; track entry.date) {
|
||||
<li class="task-sidebar__history-item">
|
||||
<span class="task-sidebar__history-state">{{ entry.state }}</span>
|
||||
<span class="task-sidebar__history-date">{{ entry.date }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="task-sidebar__section">
|
||||
<h3 class="task-sidebar__label">Notes</h3>
|
||||
<emi-task-note-list
|
||||
[notes]="task().notes"
|
||||
(addNote)="onAddNote($event)"
|
||||
(deleteNote)="onDeleteNote($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="task-sidebar__footer">
|
||||
<div class="task-sidebar__states">
|
||||
@for (state of allStates; track state) {
|
||||
<button
|
||||
class="task-sidebar__state-btn"
|
||||
[class.task-sidebar__state-btn--active]="isCurrentState(state)"
|
||||
[class.task-sidebar__state-btn--disabled]="isCurrentState(state) || isTransitioning()"
|
||||
[class.task-sidebar__state-btn--loading]="isTransitioningTo(state)"
|
||||
[disabled]="isCurrentState(state) || isTransitioning()"
|
||||
(click)="onChangeState(state)"
|
||||
>
|
||||
@if (isTransitioningTo(state)) {
|
||||
<emi-spinner size="sm" />
|
||||
}
|
||||
<span [class.task-sidebar__state-btn-text--hidden]="isTransitioningTo(state)">{{ state }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="task-sidebar__actions">
|
||||
<button class="task-sidebar__action-btn task-sidebar__action-btn--danger" [disabled]="isTransitioning()" (click)="onDeleteTask()">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { Task, TaskState } from '../../data-access/models/task.model';
|
||||
import { TaskStateBadge } from '../task-state-badge/task-state-badge';
|
||||
import { TaskNoteList } from '../task-note-list/task-note-list';
|
||||
import { Spinner } from '@shared/ui/spinner/spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-detail-sidebar',
|
||||
imports: [TaskStateBadge, TaskNoteList, Spinner],
|
||||
templateUrl: './task-detail-sidebar.html',
|
||||
styleUrl: './task-detail-sidebar.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskDetailSidebar {
|
||||
task = input.required<Task>();
|
||||
transitioningState = input<TaskState | null>(null);
|
||||
|
||||
close = output<void>();
|
||||
changeState = output<TaskState>();
|
||||
deleteTask = output<void>();
|
||||
addNote = output<string>();
|
||||
deleteNote = output<number>();
|
||||
|
||||
private readonly stateProgress: Record<TaskState, number> = {
|
||||
new: 0,
|
||||
active: 50,
|
||||
resolved: 100,
|
||||
closed: 100,
|
||||
};
|
||||
|
||||
readonly allStates: TaskState[] = ['new', 'active', 'resolved', 'closed'];
|
||||
|
||||
readonly currentState = computed<TaskState>(() => {
|
||||
const task = this.task();
|
||||
if (task.stateHistory.length === 0) return 'new';
|
||||
return task.stateHistory[task.stateHistory.length - 1].state;
|
||||
});
|
||||
|
||||
isCurrentState(state: TaskState): boolean {
|
||||
return this.currentState() === state;
|
||||
}
|
||||
|
||||
isTransitioningTo(state: TaskState): boolean {
|
||||
return this.transitioningState() === state;
|
||||
}
|
||||
|
||||
isTransitioning(): boolean {
|
||||
return this.transitioningState() !== null;
|
||||
}
|
||||
|
||||
getStateProgress(): number {
|
||||
return this.stateProgress[this.currentState()];
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
onChangeState(state: TaskState): void {
|
||||
this.changeState.emit(state);
|
||||
}
|
||||
|
||||
onDeleteTask(): void {
|
||||
this.deleteTask.emit();
|
||||
}
|
||||
|
||||
onAddNote(content: string): void {
|
||||
this.addNote.emit(content);
|
||||
}
|
||||
|
||||
onDeleteNote(index: number): void {
|
||||
this.deleteNote.emit(index);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<p>task-form works!</p>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { CreateTaskDto, UpdateTaskDto } from '../../data-access/models/task.model';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-form',
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './task-form.html',
|
||||
styleUrl: './task-form.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskForm {
|
||||
form = input.required<FormGroup>();
|
||||
isEditing = input(false);
|
||||
submitLabel = input('Save');
|
||||
loading = input(false);
|
||||
|
||||
submitted = output<CreateTaskDto | UpdateTaskDto>();
|
||||
cancelled = output<void>();
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form().valid) {
|
||||
this.submitted.emit(this.form().getRawValue());
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
.note-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.note-list__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-page);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--lh-normal);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.note-list__item:hover {
|
||||
background: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.note-list__content {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.note-list__delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.note-list__delete:hover {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.note-list__delete:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.note-list__empty {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<ul class="note-list" role="list" aria-label="Task notes">
|
||||
@for (note of notes(); track $index) {
|
||||
<li class="note-list__item" role="listitem">
|
||||
<span class="note-list__content">{{ note }}</span>
|
||||
<button
|
||||
class="note-list__delete"
|
||||
(click)="onDeleteNote($index)"
|
||||
[attr.aria-label]="'Delete note: ' + note"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span class="sr-only">Delete note</span>
|
||||
</button>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="note-list__empty" role="listitem">No notes yet</li>
|
||||
}
|
||||
</ul>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-note-list',
|
||||
imports: [],
|
||||
templateUrl: './task-note-list.html',
|
||||
styleUrl: './task-note-list.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskNoteList {
|
||||
notes = input.required<string[]>();
|
||||
loading = input(false);
|
||||
|
||||
addNote = output<string>();
|
||||
deleteNote = output<number>();
|
||||
|
||||
onAddNote(content: string): void {
|
||||
if (content.trim()) {
|
||||
this.addNote.emit(content);
|
||||
}
|
||||
}
|
||||
|
||||
onDeleteNote(index: number): void {
|
||||
this.deleteNote.emit(index);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
@keyframes badge-pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: capitalize;
|
||||
transition: background var(--transition-base), color var(--transition-base), box-shadow var(--transition-base);
|
||||
animation: badge-pop var(--transition-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.state-badge--new {
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
.state-badge--active {
|
||||
background: #FEF3C7;
|
||||
color: #92400E;
|
||||
}
|
||||
|
||||
.state-badge--resolved {
|
||||
background: #D1FAE5;
|
||||
color: #065F46;
|
||||
}
|
||||
|
||||
.state-badge--closed {
|
||||
background: var(--color-gray-200);
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<span
|
||||
class="state-badge state-badge--{{ state() }}"
|
||||
role="status"
|
||||
[attr.aria-label]="'Task state: ' + stateLabel"
|
||||
>
|
||||
{{ stateLabel }}
|
||||
</span>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { TaskState } from '../../data-access/models/task.model';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-task-state-badge',
|
||||
imports: [],
|
||||
templateUrl: './task-state-badge.html',
|
||||
styleUrl: './task-state-badge.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskStateBadge {
|
||||
state = input.required<TaskState>();
|
||||
|
||||
get stateLabel(): string {
|
||||
const labels: Record<TaskState, string> = {
|
||||
new: 'New',
|
||||
active: 'Active',
|
||||
resolved: 'Resolved',
|
||||
closed: 'Closed',
|
||||
};
|
||||
return labels[this.state()];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export { Button } from './ui/button/button';
|
||||
export { Card } from './ui/card/card';
|
||||
export { FormField } from './ui/form-field/form-field';
|
||||
export { Pagination } from './ui/pagination/pagination';
|
||||
export { Modal } from './ui/modal/modal';
|
||||
export { Spinner } from './ui/spinner/spinner';
|
||||
export { NotFound } from './ui/not-found/not-found';
|
||||
|
||||
export { SafeDatePipe } from './pipes/safe-date-pipe';
|
||||
|
||||
export { futureDateValidator, atLeastOneFilledValidator, trimmedRequiredValidator } from './utils/form-validators.util';
|
||||
export { toISODateString, isFutureDate, formatRelative } from './utils/date.util';
|
||||
export type { Result } from './models/result.model';
|
||||
export { ok, err, isOk, isErr } from './models/result.model';
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export type Result<T, E = Error> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: E };
|
||||
|
||||
export function ok<T>(data: T): Result<T, never> {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Result<never, E> {
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
export function isOk<T, E>(result: Result<T, E>): result is { success: true; data: T } {
|
||||
return result.success;
|
||||
}
|
||||
|
||||
export function isErr<T, E>(result: Result<T, E>): result is { success: false; error: E } {
|
||||
return !result.success;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { formatRelative } from '../utils/date.util';
|
||||
|
||||
@Pipe({
|
||||
name: 'safeDate',
|
||||
})
|
||||
export class SafeDatePipe implements PipeTransform {
|
||||
transform(value: string | Date | null): string {
|
||||
if (!value) return '';
|
||||
return formatRelative(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--fw-semibold);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
text-decoration: none;
|
||||
min-height: 44px;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--fs-base);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-white);
|
||||
box-shadow: var(--shadow-brand);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn--primary:active:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-active);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
background-color: transparent;
|
||||
color: var(--color-brand-primary);
|
||||
border: 2px solid var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.btn--outline:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background-color: var(--color-gray-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background-color: transparent;
|
||||
color: var(--color-danger);
|
||||
border: 1px solid var(--color-danger);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background-color: var(--color-danger);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--fs-sm);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: var(--space-4) var(--space-8);
|
||||
font-size: var(--fs-md);
|
||||
min-height: 52px;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<button
|
||||
class="btn"
|
||||
[class.btn--primary]="variant() === 'primary'"
|
||||
[class.btn--outline]="variant() === 'outline'"
|
||||
[class.btn--ghost]="variant() === 'ghost'"
|
||||
[class.btn--danger]="variant() === 'danger'"
|
||||
[class.btn--sm]="size() === 'sm'"
|
||||
[class.btn--lg]="size() === 'lg'"
|
||||
[disabled]="disabled()"
|
||||
[type]="buttonType()"
|
||||
(click)="onClick()"
|
||||
>
|
||||
<ng-content />
|
||||
</button>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-button',
|
||||
imports: [],
|
||||
templateUrl: './button.html',
|
||||
styleUrl: './button.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Button {
|
||||
variant = input<'primary' | 'outline' | 'ghost' | 'danger'>('primary');
|
||||
size = input<'sm' | 'md' | 'lg'>('md');
|
||||
disabled = input(false);
|
||||
buttonType = input<'button' | 'submit' | 'reset'>('button');
|
||||
clicked = output<void>();
|
||||
|
||||
onClick(): void {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
.card {
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card--elevated {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.card--bordered {
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card--inverse {
|
||||
background-color: var(--bg-inverse);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<div
|
||||
class="card"
|
||||
[class.card--elevated]="elevated()"
|
||||
[class.card--bordered]="bordered()"
|
||||
[class.card--inverse]="inverse()"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-card',
|
||||
imports: [],
|
||||
templateUrl: './card.html',
|
||||
styleUrl: './card.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Card {
|
||||
elevated = input(false);
|
||||
bordered = input(false);
|
||||
inverse = input(false);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.form-field--error .form-field__input {
|
||||
border-color: var(--state-error);
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--fs-base);
|
||||
font-family: var(--font-body);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast);
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-field__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(230, 57, 70, 0.15);
|
||||
}
|
||||
|
||||
.form-field__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-field__error {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--state-error);
|
||||
}
|
||||
|
||||
.form-field__helper {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<div class="form-field" [class.form-field--error]="hasError()">
|
||||
<label class="form-field__label" [for]="inputId()">
|
||||
{{ label() }}
|
||||
@if (required()) {
|
||||
<span class="form-field__required">*</span>
|
||||
}
|
||||
</label>
|
||||
<ng-content select="input, textarea, select" />
|
||||
@if (hasError() && errorMessage()) {
|
||||
<span class="form-field__error">{{ errorMessage() }}</span>
|
||||
}
|
||||
@if (helperText()) {
|
||||
<span class="form-field__helper">{{ helperText() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-form-field',
|
||||
imports: [],
|
||||
templateUrl: './form-field.html',
|
||||
styleUrl: './form-field.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormField {
|
||||
inputId = input.required<string>();
|
||||
label = input.required<string>();
|
||||
required = input(false);
|
||||
hasError = input(false);
|
||||
errorMessage = input<string>();
|
||||
helperText = input<string>();
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<p>modal works!</p>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-modal',
|
||||
imports: [],
|
||||
templateUrl: './modal.html',
|
||||
styleUrl: './modal.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Modal {}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
.not-found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.not-found__code {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-3xl);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--color-brand-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.not-found__title {
|
||||
font-size: var(--fs-xl);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.not-found__description {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-8);
|
||||
max-width: 480px;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<section class="not-found" aria-label="Page not found">
|
||||
<span class="not-found__code" aria-hidden="true">404</span>
|
||||
<h1 class="not-found__title">Page not found</h1>
|
||||
<p class="not-found__description">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<a routerLink="/tasks" class="btn btn--primary" aria-label="Go to task list">
|
||||
Go to Tasks
|
||||
</a>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-not-found',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './not-found.html',
|
||||
styleUrl: './not-found.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NotFound {}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) 0;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination__info {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.pagination__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.pagination__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--fs-sm);
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pagination__btn:hover:not(:disabled):not(.pagination__btn--active) {
|
||||
background-color: var(--color-gray-100);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.pagination__btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pagination__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination__btn--active {
|
||||
background-color: var(--color-brand-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.pagination__btn--active:hover:not(:disabled) {
|
||||
background-color: var(--color-brand-primary-hover);
|
||||
border-color: var(--color-brand-primary-hover);
|
||||
}
|
||||
|
||||
.pagination__btn--prev,
|
||||
.pagination__btn--next {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pagination__ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<nav class="pagination" aria-label="Pagination">
|
||||
<div class="pagination__info">
|
||||
Showing {{ startItem() }} to {{ endItem() }} of {{ totalItems() }} tasks
|
||||
</div>
|
||||
|
||||
<div class="pagination__controls">
|
||||
<button
|
||||
class="pagination__btn pagination__btn--prev"
|
||||
[disabled]="currentPage() === 1"
|
||||
(click)="onPrevious()"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@for (page of pages(); track page) {
|
||||
@if (page === -1) {
|
||||
<span class="pagination__ellipsis" aria-hidden="true">...</span>
|
||||
} @else {
|
||||
<button
|
||||
class="pagination__btn pagination__btn--page"
|
||||
[class.pagination__btn--active]="page === currentPage()"
|
||||
(click)="onPageChange(page)"
|
||||
[attr.aria-label]="'Page ' + page"
|
||||
[attr.aria-current]="page === currentPage() ? 'page' : null"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
<button
|
||||
class="pagination__btn pagination__btn--next"
|
||||
[disabled]="currentPage() === totalPages()"
|
||||
(click)="onNext()"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { ChangeDetectionStrategy, Component, input, output, computed } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-pagination',
|
||||
imports: [],
|
||||
templateUrl: './pagination.html',
|
||||
styleUrl: './pagination.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Pagination {
|
||||
currentPage = input.required<number>();
|
||||
totalPages = input.required<number>();
|
||||
totalItems = input.required<number>();
|
||||
pageSize = input.required<number>();
|
||||
|
||||
pageChange = output<number>();
|
||||
|
||||
readonly pages = computed(() => {
|
||||
const result: number[] = [];
|
||||
const total = this.totalPages();
|
||||
const current = this.currentPage();
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
} else {
|
||||
result.push(1);
|
||||
|
||||
if (current > 3) {
|
||||
result.push(-1);
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
result.push(-1);
|
||||
}
|
||||
|
||||
result.push(total);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly startItem = computed(() => (this.currentPage() - 1) * this.pageSize() + 1);
|
||||
|
||||
readonly endItem = computed(() => Math.min(this.currentPage() * this.pageSize(), this.totalItems()));
|
||||
|
||||
onPageChange(page: number): void {
|
||||
if (page >= 1 && page <= this.totalPages()) {
|
||||
this.pageChange.emit(page);
|
||||
}
|
||||
}
|
||||
|
||||
onPrevious(): void {
|
||||
this.onPageChange(this.currentPage() - 1);
|
||||
}
|
||||
|
||||
onNext(): void {
|
||||
this.onPageChange(this.currentPage() + 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
.spinner {
|
||||
display: inline-block;
|
||||
border: 3px solid var(--color-gray-200);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner--sm {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.spinner--md {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.spinner--lg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<span
|
||||
class="spinner"
|
||||
[class.spinner--sm]="size() === 'sm'"
|
||||
[class.spinner--md]="size() === 'md'"
|
||||
[class.spinner--lg]="size() === 'lg'"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
></span>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-spinner',
|
||||
imports: [],
|
||||
templateUrl: './spinner.html',
|
||||
styleUrl: './spinner.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Spinner {
|
||||
size = input<'sm' | 'md' | 'lg'>('md');
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.toast--warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.toast__content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast__icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.toast__message {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.toast__close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<div class="toast-container" aria-live="polite" aria-label="Notifications">
|
||||
@for (notification of notifications(); track notification.id) {
|
||||
<div
|
||||
class="toast toast--{{ notification.type }}"
|
||||
role="alert"
|
||||
>
|
||||
<div class="toast__content">
|
||||
@switch (notification.type) {
|
||||
@case ('error') {
|
||||
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('success') {
|
||||
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('warning') {
|
||||
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
}
|
||||
@default {
|
||||
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
<span class="toast__message">{{ notification.message }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="toast__close"
|
||||
(click)="onDismiss(notification.id)"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { Notification } from '@core/services/notification';
|
||||
|
||||
@Component({
|
||||
selector: 'emi-toast',
|
||||
imports: [],
|
||||
templateUrl: './toast.html',
|
||||
styleUrl: './toast.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Toast {
|
||||
private readonly notification = inject(Notification);
|
||||
|
||||
readonly notifications = this.notification.notifications;
|
||||
|
||||
onDismiss(id: number): void {
|
||||
this.notification.dismiss(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
export function toISODateString(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function isFutureDate(date: Date | string): boolean {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return d > today;
|
||||
}
|
||||
|
||||
export function formatRelative(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
||||
return `${Math.floor(diffDays / 365)} years ago`;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
||||
|
||||
export function futureDateValidator(control: AbstractControl): ValidationErrors | null {
|
||||
const value = control.value;
|
||||
if (!value) return null;
|
||||
|
||||
const date = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return date > today ? null : { futureDate: { value } };
|
||||
}
|
||||
|
||||
export function atLeastOneFilledValidator(...fields: string[]): ValidatorFn {
|
||||
return (group: AbstractControl): ValidationErrors | null => {
|
||||
const hasValue = fields.some(field => {
|
||||
const control = group.get(field);
|
||||
return control && control.value && control.value.trim() !== '';
|
||||
});
|
||||
|
||||
return hasValue ? null : { atLeastOneFilled: { fields } };
|
||||
};
|
||||
}
|
||||
|
||||
export function trimmedRequiredValidator(control: AbstractControl): ValidationErrors | null {
|
||||
const value = control.value;
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return null;
|
||||
}
|
||||
return { trimmedRequired: { value } };
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:3000/api',
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://api.emi.challenge.berand97.dev/api',
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>EMI Task Management</title>
|
||||
<meta name="description" content="EMI Task Management - Gestión de tareas eficiente y profesional">
|
||||
<meta name="theme-color" content="#6B1F2A">
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Saltar al contenido</a>
|
||||
<emi-root></emi-root>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap');
|
||||
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--color-brand-primary: #E63946;
|
||||
--color-brand-primary-hover: #C92A3B;
|
||||
--color-brand-primary-active: #A8202F;
|
||||
--color-brand-secondary: #6B1F2A;
|
||||
--color-brand-secondary-dark: #4D1620;
|
||||
--color-brand-accent: #D32F2F;
|
||||
|
||||
/* Support Colors */
|
||||
--color-success: #25D366;
|
||||
--color-success-dark: #1EA952;
|
||||
--color-warning: #F59E0B;
|
||||
--color-danger: #DC2626;
|
||||
--color-info: #0EA5E9;
|
||||
|
||||
/* Neutrals */
|
||||
--color-white: #FFFFFF;
|
||||
--color-gray-50: #F9FAFB;
|
||||
--color-gray-100: #F3F4F6;
|
||||
--color-gray-200: #E5E7EB;
|
||||
--color-gray-300: #D1D5DB;
|
||||
--color-gray-400: #9CA3AF;
|
||||
--color-gray-500: #6B7280;
|
||||
--color-gray-600: #4B5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1F2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* Semantic Tokens */
|
||||
--bg-page: var(--color-gray-50);
|
||||
--bg-surface: var(--color-white);
|
||||
--bg-elevated: var(--color-white);
|
||||
--bg-inverse: var(--color-brand-secondary);
|
||||
--bg-overlay: rgba(17, 24, 39, 0.6);
|
||||
|
||||
--text-primary: var(--color-gray-900);
|
||||
--text-secondary: var(--color-gray-600);
|
||||
--text-muted: var(--color-gray-400);
|
||||
--text-inverse: var(--color-white);
|
||||
--text-link: var(--color-brand-primary);
|
||||
--text-link-hover: var(--color-brand-primary-hover);
|
||||
|
||||
--border-subtle: var(--color-gray-200);
|
||||
--border-default: var(--color-gray-300);
|
||||
--border-strong: var(--color-gray-400);
|
||||
--border-focus: var(--color-brand-primary);
|
||||
|
||||
--state-error: var(--color-danger);
|
||||
--state-success: var(--color-success);
|
||||
--state-warning: var(--color-warning);
|
||||
|
||||
/* Typography */
|
||||
--font-display: 'Sora', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, monospace;
|
||||
|
||||
--fs-xs: 0.75rem;
|
||||
--fs-sm: 0.875rem;
|
||||
--fs-base: 1rem;
|
||||
--fs-md: 1.125rem;
|
||||
--fs-lg: clamp(1.25rem, 1rem + 1vw, 1.5rem);
|
||||
--fs-xl: clamp(1.5rem, 1.2rem + 1.5vw, 2rem);
|
||||
--fs-2xl: clamp(1.875rem, 1.5rem + 2vw, 2.5rem);
|
||||
--fs-3xl: clamp(2.25rem, 1.8rem + 3vw, 3.5rem);
|
||||
|
||||
--fw-regular: 400;
|
||||
--fw-medium: 500;
|
||||
--fw-semibold: 600;
|
||||
--fw-bold: 700;
|
||||
|
||||
--lh-tight: 1.1;
|
||||
--lh-snug: 1.25;
|
||||
--lh-normal: 1.5;
|
||||
--lh-relaxed: 1.65;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.05em;
|
||||
|
||||
/* Spacing */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-none: 0;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-2xl: 24px;
|
||||
--radius-pill: 9999px;
|
||||
--radius-full: 50%;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-brand: 0 10px 25px -5px rgba(230, 57, 70, 0.35);
|
||||
|
||||
/* Z-index */
|
||||
--z-base: 0;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-fixed: 300;
|
||||
--z-modal-backdrop: 400;
|
||||
--z-modal: 500;
|
||||
--z-toast: 800;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
|
||||
/* Container */
|
||||
--container-xl: 1200px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-page: #0F0A0B;
|
||||
--bg-surface: #1A1214;
|
||||
--bg-elevated: #241719;
|
||||
--text-primary: #F5F5F5;
|
||||
--text-secondary: #B8B8B8;
|
||||
--border-subtle: #2D1F22;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-page);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--fw-bold);
|
||||
line-height: var(--lh-snug);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h1 { font-size: var(--fs-3xl); }
|
||||
h2 { font-size: var(--fs-2xl); }
|
||||
h3 { font-size: var(--fs-xl); }
|
||||
h4 { font-size: var(--fs-lg); }
|
||||
|
||||
a {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--text-link-hover);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--container-xl);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding-inline: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-white);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
z-index: var(--z-toast);
|
||||
transition: top var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
Loading…
Reference in New Issue