diff --git a/src/app/app.config.ts b/src/app/app.config.ts new file mode 100644 index 0000000..86f6795 --- /dev/null +++ b/src/app/app.config.ts @@ -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, + }, + ], +}; diff --git a/src/app/app.css b/src/app/app.css new file mode 100644 index 0000000..57d8f00 --- /dev/null +++ b/src/app/app.css @@ -0,0 +1,5 @@ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} diff --git a/src/app/app.html b/src/app/app.html new file mode 100644 index 0000000..e0814fe --- /dev/null +++ b/src/app/app.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts new file mode 100644 index 0000000..727a6f6 --- /dev/null +++ b/src/app/app.routes.ts @@ -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, + }, +]; diff --git a/src/app/app.ts b/src/app/app.ts new file mode 100644 index 0000000..dc9a4c0 --- /dev/null +++ b/src/app/app.ts @@ -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'); +} diff --git a/src/app/core/guards/unsaved-changes-guard.ts b/src/app/core/guards/unsaved-changes-guard.ts new file mode 100644 index 0000000..6c1a62e --- /dev/null +++ b/src/app/core/guards/unsaved-changes-guard.ts @@ -0,0 +1,9 @@ +import { CanDeactivateFn } from '@angular/router'; + +export interface CanComponentDeactivate { + canDeactivate: () => boolean; +} + +export const unsavedChangesGuard: CanDeactivateFn = (component) => { + return component.canDeactivate ? component.canDeactivate() : true; +}; diff --git a/src/app/core/index.ts b/src/app/core/index.ts new file mode 100644 index 0000000..19de2c7 --- /dev/null +++ b/src/app/core/index.ts @@ -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'; diff --git a/src/app/core/interceptors/api-response-interceptor.ts b/src/app/core/interceptors/api-response-interceptor.ts new file mode 100644 index 0000000..03178a2 --- /dev/null +++ b/src/app/core/interceptors/api-response-interceptor.ts @@ -0,0 +1,22 @@ +import { HttpInterceptorFn, HttpResponse } from '@angular/common/http'; +import { map } from 'rxjs'; + +interface ApiResponse { + 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; + + if ('success' in body && 'data' in body) { + return event.clone({ body: body.data }); + } + } + return event; + }) + ); +}; diff --git a/src/app/core/interceptors/error-interceptor.ts b/src/app/core/interceptors/error-interceptor.ts new file mode 100644 index 0000000..78226f4 --- /dev/null +++ b/src/app/core/interceptors/error-interceptor.ts @@ -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); + }) + ); +}; diff --git a/src/app/core/interceptors/loading-interceptor.ts b/src/app/core/interceptors/loading-interceptor.ts new file mode 100644 index 0000000..f8096f4 --- /dev/null +++ b/src/app/core/interceptors/loading-interceptor.ts @@ -0,0 +1,5 @@ +import { HttpInterceptorFn } from '@angular/common/http'; + +export const loadingInterceptor: HttpInterceptorFn = (req, next) => { + return next(req); +}; diff --git a/src/app/core/models/api-error.model.ts b/src/app/core/models/api-error.model.ts new file mode 100644 index 0000000..d6f73d6 --- /dev/null +++ b/src/app/core/models/api-error.model.ts @@ -0,0 +1,6 @@ +export interface ApiError { + status: number; + message: string; + timestamp: string; + path?: string; +} diff --git a/src/app/core/models/pagination.model.ts b/src/app/core/models/pagination.model.ts new file mode 100644 index 0000000..a00be9c --- /dev/null +++ b/src/app/core/models/pagination.model.ts @@ -0,0 +1,16 @@ +export interface Pagination { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export interface PaginatedResponse { + data: T[]; + pagination: Pagination; +} + +export interface PaginationParams { + page?: number; + pageSize?: number; +} diff --git a/src/app/core/providers.ts b/src/app/core/providers.ts new file mode 100644 index 0000000..31e2cc5 --- /dev/null +++ b/src/app/core/providers.ts @@ -0,0 +1,5 @@ +import { type EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; + +export function provideCore(): EnvironmentProviders { + return makeEnvironmentProviders([]); +} diff --git a/src/app/core/services/loading.ts b/src/app/core/services/loading.ts new file mode 100644 index 0000000..be775bb --- /dev/null +++ b/src/app/core/services/loading.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class Loading {} diff --git a/src/app/core/services/notification.ts b/src/app/core/services/notification.ts new file mode 100644 index 0000000..d78b294 --- /dev/null +++ b/src/app/core/services/notification.ts @@ -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([]); + 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)); + } +} diff --git a/src/app/features/.gitkeep b/src/app/features/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/tasks/data-access/data-sources/http-task.data-source.ts b/src/app/features/tasks/data-access/data-sources/http-task.data-source.ts new file mode 100644 index 0000000..c54bad6 --- /dev/null +++ b/src/app/features/tasks/data-access/data-sources/http-task.data-source.ts @@ -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> { + return this.http.get(this.baseUrl).pipe( + map(tasks => ({ + data: tasks, + pagination: { + page: 1, + pageSize: tasks.length, + total: tasks.length, + totalPages: 1, + }, + })) + ); + } + + getById(id: string): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + create(dto: CreateTaskDto): Observable { + return this.http.post(this.baseUrl, dto); + } + + update(id: string, dto: UpdateTaskDto): Observable { + return this.http.put(`${this.baseUrl}/${id}`, dto); + } + + delete(id: string): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } + + transition(id: string, state: TaskState): Observable { + return this.http.patch(`${this.baseUrl}/${id}/transition`, { state }); + } + + addNote(taskId: string, note: string): Observable { + return this.http.post(`${this.baseUrl}/${taskId}/notes`, { note }); + } + + deleteNote(taskId: string, noteIndex: number): Observable { + return this.http.delete(`${this.baseUrl}/${taskId}/notes/${noteIndex}`); + } +} diff --git a/src/app/features/tasks/data-access/data-sources/task-data-source.interface.ts b/src/app/features/tasks/data-access/data-sources/task-data-source.interface.ts new file mode 100644 index 0000000..c7e9dc4 --- /dev/null +++ b/src/app/features/tasks/data-access/data-sources/task-data-source.interface.ts @@ -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>; + getById(id: string): Observable; + create(dto: CreateTaskDto): Observable; + update(id: string, dto: UpdateTaskDto): Observable; + delete(id: string): Observable; + transition(id: string, state: TaskState): Observable; + addNote(taskId: string, note: string): Observable; + deleteNote(taskId: string, noteIndex: number): Observable; +} diff --git a/src/app/features/tasks/data-access/data-sources/task-data-source.token.ts b/src/app/features/tasks/data-access/data-sources/task-data-source.token.ts new file mode 100644 index 0000000..c564408 --- /dev/null +++ b/src/app/features/tasks/data-access/data-sources/task-data-source.token.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import { TaskDataSource } from './task-data-source.interface'; + +export const TASK_DATA_SOURCE = new InjectionToken('TaskDataSource'); diff --git a/src/app/features/tasks/data-access/index.ts b/src/app/features/tasks/data-access/index.ts new file mode 100644 index 0000000..8419dbd --- /dev/null +++ b/src/app/features/tasks/data-access/index.ts @@ -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'; diff --git a/src/app/features/tasks/data-access/models/task-dto.model.ts b/src/app/features/tasks/data-access/models/task-dto.model.ts new file mode 100644 index 0000000..86161af --- /dev/null +++ b/src/app/features/tasks/data-access/models/task-dto.model.ts @@ -0,0 +1,8 @@ +export interface TaskDto { + id: string; + title: string; + description: string; + dueDate: string; + stateHistory: { state: string; date: string }[]; + notes: string[]; +} diff --git a/src/app/features/tasks/data-access/models/task-state.model.ts b/src/app/features/tasks/data-access/models/task-state.model.ts new file mode 100644 index 0000000..760aa8b --- /dev/null +++ b/src/app/features/tasks/data-access/models/task-state.model.ts @@ -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', +}; diff --git a/src/app/features/tasks/data-access/models/task.model.ts b/src/app/features/tasks/data-access/models/task.model.ts new file mode 100644 index 0000000..59bb131 --- /dev/null +++ b/src/app/features/tasks/data-access/models/task.model.ts @@ -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: [], + }; +} diff --git a/src/app/features/tasks/data-access/resolvers/task-resolver.ts b/src/app/features/tasks/data-access/resolvers/task-resolver.ts new file mode 100644 index 0000000..79d18fd --- /dev/null +++ b/src/app/features/tasks/data-access/resolvers/task-resolver.ts @@ -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 = 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; + } +}; diff --git a/src/app/features/tasks/data-access/services/task.ts b/src/app/features/tasks/data-access/services/task.ts new file mode 100644 index 0000000..ebec95d --- /dev/null +++ b/src/app/features/tasks/data-access/services/task.ts @@ -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(TASK_DATA_SOURCE); + + getAll(params?: PaginationParams): Observable> { + return this.dataSource.getAll(params); + } + + getById(id: string): Observable { + return this.dataSource.getById(id); + } + + create(dto: CreateTaskDto): Observable { + return this.dataSource.create(dto); + } + + update(id: string, dto: UpdateTaskDto): Observable { + return this.dataSource.update(id, dto); + } + + delete(id: string): Observable { + return this.dataSource.delete(id); + } + + transition(id: string, state: TaskState): Observable { + return this.dataSource.transition(id, state); + } + + addNote(taskId: string, note: string): Observable { + return this.dataSource.addNote(taskId, note); + } + + deleteNote(taskId: string, noteIndex: number): Observable { + return this.dataSource.deleteNote(taskId, noteIndex); + } +} diff --git a/src/app/features/tasks/data-access/store/task-store.ts b/src/app/features/tasks/data-access/store/task-store.ts new file mode 100644 index 0000000..4edb8a1 --- /dev/null +++ b/src/app/features/tasks/data-access/store/task-store.ts @@ -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([]); + private readonly selectedTaskSignal = signal(null); + private readonly filterSignal = signal(defaultFilter); + private readonly sortSignal = signal(defaultSort); + private readonly loadingSignal = signal(false); + private readonly errorSignal = signal(null); + private readonly paginationSignal = signal['pagination'] | null>(null); + private readonly transitioningStateSignal = signal(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): 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); + } +} diff --git a/src/app/features/tasks/feature/task-create-page/task-create-page.css b/src/app/features/tasks/feature/task-create-page/task-create-page.css new file mode 100644 index 0000000..ffb7118 --- /dev/null +++ b/src/app/features/tasks/feature/task-create-page/task-create-page.css @@ -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); + } +} diff --git a/src/app/features/tasks/feature/task-create-page/task-create-page.html b/src/app/features/tasks/feature/task-create-page/task-create-page.html new file mode 100644 index 0000000..68181ab --- /dev/null +++ b/src/app/features/tasks/feature/task-create-page/task-create-page.html @@ -0,0 +1,94 @@ +
+
+

Create Task

+
+ + @if (error()) { + + } + +
+
+ + + Enter a descriptive title for the task +
+ +
+ + + Optional: provide additional details about the task +
+ +
+ + + Select when this task should be completed +
+ +
+ + + Required: add at least one note to the task +
+ +
+ + Cancel + + + {{ loading() ? 'Creating...' : 'Create Task' }} + +
+
+
diff --git a/src/app/features/tasks/feature/task-create-page/task-create-page.ts b/src/app/features/tasks/feature/task-create-page/task-create-page.ts new file mode 100644 index 0000000..e51ad63 --- /dev/null +++ b/src/app/features/tasks/feature/task-create-page/task-create-page.ts @@ -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(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; + } +} diff --git a/src/app/features/tasks/feature/task-detail-page/task-detail-page.css b/src/app/features/tasks/feature/task-detail-page/task-detail-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/tasks/feature/task-detail-page/task-detail-page.html b/src/app/features/tasks/feature/task-detail-page/task-detail-page.html new file mode 100644 index 0000000..ae9de15 --- /dev/null +++ b/src/app/features/tasks/feature/task-detail-page/task-detail-page.html @@ -0,0 +1,47 @@ +
+ @if (loading()) { +

Loading...

+ } + + @if (error()) { +

{{ error() }}

+ } + + @if (task(); as task) { +
+ +

{{ task.title }}

+ +
+ +
+

Description: {{ task.description }}

+

Due Date: {{ task.dueDate }}

+
+ +
+

State History

+
    + @for (entry of task.stateHistory; track entry.date) { +
  • {{ entry.state }} - {{ entry.date }}
  • + } +
+
+ +
+

Notes

+
    + @for (note of task.notes; track $index) { +
  • + {{ note }} + +
  • + } +
+
+ +
+ +
+ } +
diff --git a/src/app/features/tasks/feature/task-detail-page/task-detail-page.ts b/src/app/features/tasks/feature/task-detail-page/task-detail-page.ts new file mode 100644 index 0000000..7585cd5 --- /dev/null +++ b/src/app/features/tasks/feature/task-detail-page/task-detail-page.ts @@ -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']); + } +} diff --git a/src/app/features/tasks/feature/task-list-page/task-list-page.css b/src/app/features/tasks/feature/task-list-page/task-list-page.css new file mode 100644 index 0000000..6e05c32 --- /dev/null +++ b/src/app/features/tasks/feature/task-list-page/task-list-page.css @@ -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; +} \ No newline at end of file diff --git a/src/app/features/tasks/feature/task-list-page/task-list-page.html b/src/app/features/tasks/feature/task-list-page/task-list-page.html new file mode 100644 index 0000000..b202383 --- /dev/null +++ b/src/app/features/tasks/feature/task-list-page/task-list-page.html @@ -0,0 +1,249 @@ +
+
+
+ +

Task Board

+
+
+ +
+
+ + + +
+
+ Sprint Progress + {{ completionPercent() }}% complete +
+
+
+
+
+ + @if (loading()) { +
+

Loading tasks...

+
+ } + + @if (!loading()) { + @switch (activeView()) { + @case ('table') { + + + + + + + + + + + + @for (task of paginatedTasks(); track task.id) { + + + + + + + + } @empty { + + + + } + +
TaskProgressStatusDue DateNotes
+ {{ task.title }} + {{ task.description }} + +
+
+
+
+ {{ getStateProgress(task) }}% +
+
+ + {{ getStateLabel(task) }} + + + + + {{ task.dueDate }} + + + {{ task.notes.length }} {{ task.notes.length === 1 ? 'note' : 'notes' }} +
No tasks found
+ + @if (tasks().length > PAGE_SIZE) { + + } + +
+
+ + + {{ completedCount() }} Completed + + + + {{ activeCount() }} Active + + + + {{ newCount() }} New + +
+ {{ tasks().length }} tasks total +
+ } + + @case ('board') { +
+ + + + +
+ } + + @case ('timeline') { +
+ @for (date of dueDates(); track date) { +
+
+ + {{ date }} +
+
+ @for (task of tasksByDate()[date]; track task.id) { +
+
+ {{ task.title }} + + {{ getStateLabel(task) }} + +
+ @if (task.description) { +

{{ task.description }}

+ } + +
+ } +
+
+ } @empty { +
No tasks found
+ } +
+ } + } + } +
+ +@if (selectedTask(); as task) { + +} + +@if (showCreateSidebar()) { + +} diff --git a/src/app/features/tasks/feature/task-list-page/task-list-page.ts b/src/app/features/tasks/feature/task-list-page/task-list-page.ts new file mode 100644 index 0000000..47c1ef6 --- /dev/null +++ b/src/app/features/tasks/feature/task-list-page/task-list-page.ts @@ -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('table'); + readonly currentPage = signal(1); + + private readonly stateProgress: Record = { + new: 0, + active: 50, + resolved: 100, + closed: 100, + }; + + private readonly stateLabels: Record = { + 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 = { + 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 = {}; + 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); + } +} diff --git a/src/app/features/tasks/tasks.routes.ts b/src/app/features/tasks/tasks.routes.ts new file mode 100644 index 0000000..ce6b8f0 --- /dev/null +++ b/src/app/features/tasks/tasks.routes.ts @@ -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 }, + }, +]; diff --git a/src/app/features/tasks/ui/task-board-column/task-board-column.css b/src/app/features/tasks/ui/task-board-column/task-board-column.css new file mode 100644 index 0000000..cef2942 --- /dev/null +++ b/src/app/features/tasks/ui/task-board-column/task-board-column.css @@ -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; +} diff --git a/src/app/features/tasks/ui/task-board-column/task-board-column.html b/src/app/features/tasks/ui/task-board-column/task-board-column.html new file mode 100644 index 0000000..ef42757 --- /dev/null +++ b/src/app/features/tasks/ui/task-board-column/task-board-column.html @@ -0,0 +1,37 @@ +
+
+
+ +

{{ title() }}

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

{{ task.description }}

+ } + +
+ } @empty { +
No tasks
+ } +
+
diff --git a/src/app/features/tasks/ui/task-board-column/task-board-column.ts b/src/app/features/tasks/ui/task-board-column/task-board-column.ts new file mode 100644 index 0000000..b6c0805 --- /dev/null +++ b/src/app/features/tasks/ui/task-board-column/task-board-column.ts @@ -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(); + title = input.required(); + tasks = input.required(); + + view = output(); + edit = output(); + delete = output(); + add = output(); + + 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(); + } +} diff --git a/src/app/features/tasks/ui/task-card/task-card.css b/src/app/features/tasks/ui/task-card/task-card.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/tasks/ui/task-card/task-card.html b/src/app/features/tasks/ui/task-card/task-card.html new file mode 100644 index 0000000..36a7dc0 --- /dev/null +++ b/src/app/features/tasks/ui/task-card/task-card.html @@ -0,0 +1,18 @@ +
+
+

{{ task().title }}

+ +
+ +

{{ task().description }}

+ +
+ Due: {{ task().dueDate }} +
+ +
+ + + +
+
diff --git a/src/app/features/tasks/ui/task-card/task-card.ts b/src/app/features/tasks/ui/task-card/task-card.ts new file mode 100644 index 0000000..8e34bec --- /dev/null +++ b/src/app/features/tasks/ui/task-card/task-card.ts @@ -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(); + view = output(); + edit = output(); + delete = output(); + + 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()); + } +} diff --git a/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.css b/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.css new file mode 100644 index 0000000..6e25e4f --- /dev/null +++ b/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.css @@ -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); +} diff --git a/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.html b/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.html new file mode 100644 index 0000000..e555ae7 --- /dev/null +++ b/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.html @@ -0,0 +1,70 @@ + diff --git a/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.ts b/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.ts new file mode 100644 index 0000000..fc6ed27 --- /dev/null +++ b/src/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.ts @@ -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(); + created = output(); + + readonly form = this.fb.group({ + title: ['', [trimmedRequiredValidator, Validators.maxLength(100)]], + description: ['', Validators.maxLength(500)], + dueDate: this.fb.control(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(); + } + } +} diff --git a/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.css b/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.css new file mode 100644 index 0000000..accf969 --- /dev/null +++ b/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.css @@ -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)); + } +} diff --git a/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.html b/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.html new file mode 100644 index 0000000..d313ce3 --- /dev/null +++ b/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.html @@ -0,0 +1,82 @@ + diff --git a/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.ts b/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.ts new file mode 100644 index 0000000..9c70a63 --- /dev/null +++ b/src/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.ts @@ -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(); + transitioningState = input(null); + + close = output(); + changeState = output(); + deleteTask = output(); + addNote = output(); + deleteNote = output(); + + private readonly stateProgress: Record = { + new: 0, + active: 50, + resolved: 100, + closed: 100, + }; + + readonly allStates: TaskState[] = ['new', 'active', 'resolved', 'closed']; + + readonly currentState = computed(() => { + 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); + } +} diff --git a/src/app/features/tasks/ui/task-form/task-form.css b/src/app/features/tasks/ui/task-form/task-form.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/tasks/ui/task-form/task-form.html b/src/app/features/tasks/ui/task-form/task-form.html new file mode 100644 index 0000000..1bcfa60 --- /dev/null +++ b/src/app/features/tasks/ui/task-form/task-form.html @@ -0,0 +1 @@ +

task-form works!

diff --git a/src/app/features/tasks/ui/task-form/task-form.ts b/src/app/features/tasks/ui/task-form/task-form.ts new file mode 100644 index 0000000..76aa29d --- /dev/null +++ b/src/app/features/tasks/ui/task-form/task-form.ts @@ -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(); + isEditing = input(false); + submitLabel = input('Save'); + loading = input(false); + + submitted = output(); + cancelled = output(); + + onSubmit(): void { + if (this.form().valid) { + this.submitted.emit(this.form().getRawValue()); + } + } + + onCancel(): void { + this.cancelled.emit(); + } +} diff --git a/src/app/features/tasks/ui/task-note-list/task-note-list.css b/src/app/features/tasks/ui/task-note-list/task-note-list.css new file mode 100644 index 0000000..ec32ede --- /dev/null +++ b/src/app/features/tasks/ui/task-note-list/task-note-list.css @@ -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; +} diff --git a/src/app/features/tasks/ui/task-note-list/task-note-list.html b/src/app/features/tasks/ui/task-note-list/task-note-list.html new file mode 100644 index 0000000..9a4ca29 --- /dev/null +++ b/src/app/features/tasks/ui/task-note-list/task-note-list.html @@ -0,0 +1,17 @@ +
    + @for (note of notes(); track $index) { +
  • + {{ note }} + +
  • + } @empty { +
  • No notes yet
  • + } +
diff --git a/src/app/features/tasks/ui/task-note-list/task-note-list.ts b/src/app/features/tasks/ui/task-note-list/task-note-list.ts new file mode 100644 index 0000000..134db98 --- /dev/null +++ b/src/app/features/tasks/ui/task-note-list/task-note-list.ts @@ -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(); + loading = input(false); + + addNote = output(); + deleteNote = output(); + + onAddNote(content: string): void { + if (content.trim()) { + this.addNote.emit(content); + } + } + + onDeleteNote(index: number): void { + this.deleteNote.emit(index); + } +} diff --git a/src/app/features/tasks/ui/task-state-badge/task-state-badge.css b/src/app/features/tasks/ui/task-state-badge/task-state-badge.css new file mode 100644 index 0000000..bc3e475 --- /dev/null +++ b/src/app/features/tasks/ui/task-state-badge/task-state-badge.css @@ -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); +} diff --git a/src/app/features/tasks/ui/task-state-badge/task-state-badge.html b/src/app/features/tasks/ui/task-state-badge/task-state-badge.html new file mode 100644 index 0000000..fc3f964 --- /dev/null +++ b/src/app/features/tasks/ui/task-state-badge/task-state-badge.html @@ -0,0 +1,7 @@ + + {{ stateLabel }} + diff --git a/src/app/features/tasks/ui/task-state-badge/task-state-badge.ts b/src/app/features/tasks/ui/task-state-badge/task-state-badge.ts new file mode 100644 index 0000000..bcad6a1 --- /dev/null +++ b/src/app/features/tasks/ui/task-state-badge/task-state-badge.ts @@ -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(); + + get stateLabel(): string { + const labels: Record = { + new: 'New', + active: 'Active', + resolved: 'Resolved', + closed: 'Closed', + }; + return labels[this.state()]; + } +} diff --git a/src/app/shared/.gitkeep b/src/app/shared/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/index.ts b/src/app/shared/index.ts new file mode 100644 index 0000000..adb3a5f --- /dev/null +++ b/src/app/shared/index.ts @@ -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'; diff --git a/src/app/shared/models/result.model.ts b/src/app/shared/models/result.model.ts new file mode 100644 index 0000000..3b0f727 --- /dev/null +++ b/src/app/shared/models/result.model.ts @@ -0,0 +1,19 @@ +export type Result = + | { success: true; data: T } + | { success: false; error: E }; + +export function ok(data: T): Result { + return { success: true, data }; +} + +export function err(error: E): Result { + return { success: false, error }; +} + +export function isOk(result: Result): result is { success: true; data: T } { + return result.success; +} + +export function isErr(result: Result): result is { success: false; error: E } { + return !result.success; +} diff --git a/src/app/shared/pipes/safe-date-pipe.ts b/src/app/shared/pipes/safe-date-pipe.ts new file mode 100644 index 0000000..e83eab8 --- /dev/null +++ b/src/app/shared/pipes/safe-date-pipe.ts @@ -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); + } +} diff --git a/src/app/shared/ui/button/button.css b/src/app/shared/ui/button/button.css new file mode 100644 index 0000000..c88bf58 --- /dev/null +++ b/src/app/shared/ui/button/button.css @@ -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; +} diff --git a/src/app/shared/ui/button/button.html b/src/app/shared/ui/button/button.html new file mode 100644 index 0000000..ec6d00f --- /dev/null +++ b/src/app/shared/ui/button/button.html @@ -0,0 +1,14 @@ + diff --git a/src/app/shared/ui/button/button.ts b/src/app/shared/ui/button/button.ts new file mode 100644 index 0000000..9f342c8 --- /dev/null +++ b/src/app/shared/ui/button/button.ts @@ -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(); + + onClick(): void { + this.clicked.emit(); + } +} diff --git a/src/app/shared/ui/card/card.css b/src/app/shared/ui/card/card.css new file mode 100644 index 0000000..7ffefbd --- /dev/null +++ b/src/app/shared/ui/card/card.css @@ -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); + } +} diff --git a/src/app/shared/ui/card/card.html b/src/app/shared/ui/card/card.html new file mode 100644 index 0000000..282f3d9 --- /dev/null +++ b/src/app/shared/ui/card/card.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/src/app/shared/ui/card/card.ts b/src/app/shared/ui/card/card.ts new file mode 100644 index 0000000..bc9aef5 --- /dev/null +++ b/src/app/shared/ui/card/card.ts @@ -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); +} diff --git a/src/app/shared/ui/form-field/form-field.css b/src/app/shared/ui/form-field/form-field.css new file mode 100644 index 0000000..db9c8ea --- /dev/null +++ b/src/app/shared/ui/form-field/form-field.css @@ -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); +} diff --git a/src/app/shared/ui/form-field/form-field.html b/src/app/shared/ui/form-field/form-field.html new file mode 100644 index 0000000..1bfc11d --- /dev/null +++ b/src/app/shared/ui/form-field/form-field.html @@ -0,0 +1,15 @@ +
+ + + @if (hasError() && errorMessage()) { + {{ errorMessage() }} + } + @if (helperText()) { + {{ helperText() }} + } +
diff --git a/src/app/shared/ui/form-field/form-field.ts b/src/app/shared/ui/form-field/form-field.ts new file mode 100644 index 0000000..a457b07 --- /dev/null +++ b/src/app/shared/ui/form-field/form-field.ts @@ -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(); + label = input.required(); + required = input(false); + hasError = input(false); + errorMessage = input(); + helperText = input(); +} diff --git a/src/app/shared/ui/modal/modal.css b/src/app/shared/ui/modal/modal.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/ui/modal/modal.html b/src/app/shared/ui/modal/modal.html new file mode 100644 index 0000000..decdfe6 --- /dev/null +++ b/src/app/shared/ui/modal/modal.html @@ -0,0 +1 @@ +

modal works!

diff --git a/src/app/shared/ui/modal/modal.ts b/src/app/shared/ui/modal/modal.ts new file mode 100644 index 0000000..73b9269 --- /dev/null +++ b/src/app/shared/ui/modal/modal.ts @@ -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 {} diff --git a/src/app/shared/ui/not-found/not-found.css b/src/app/shared/ui/not-found/not-found.css new file mode 100644 index 0000000..2ac2338 --- /dev/null +++ b/src/app/shared/ui/not-found/not-found.css @@ -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; +} diff --git a/src/app/shared/ui/not-found/not-found.html b/src/app/shared/ui/not-found/not-found.html new file mode 100644 index 0000000..0805c36 --- /dev/null +++ b/src/app/shared/ui/not-found/not-found.html @@ -0,0 +1,10 @@ +
+ +

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+ + Go to Tasks + +
diff --git a/src/app/shared/ui/not-found/not-found.ts b/src/app/shared/ui/not-found/not-found.ts new file mode 100644 index 0000000..474f286 --- /dev/null +++ b/src/app/shared/ui/not-found/not-found.ts @@ -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 {} diff --git a/src/app/shared/ui/pagination/pagination.css b/src/app/shared/ui/pagination/pagination.css new file mode 100644 index 0000000..c17b8d4 --- /dev/null +++ b/src/app/shared/ui/pagination/pagination.css @@ -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); +} diff --git a/src/app/shared/ui/pagination/pagination.html b/src/app/shared/ui/pagination/pagination.html new file mode 100644 index 0000000..7749b40 --- /dev/null +++ b/src/app/shared/ui/pagination/pagination.html @@ -0,0 +1,45 @@ + diff --git a/src/app/shared/ui/pagination/pagination.ts b/src/app/shared/ui/pagination/pagination.ts new file mode 100644 index 0000000..2096342 --- /dev/null +++ b/src/app/shared/ui/pagination/pagination.ts @@ -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(); + totalPages = input.required(); + totalItems = input.required(); + pageSize = input.required(); + + pageChange = output(); + + 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); + } +} diff --git a/src/app/shared/ui/spinner/spinner.css b/src/app/shared/ui/spinner/spinner.css new file mode 100644 index 0000000..b9b4ef1 --- /dev/null +++ b/src/app/shared/ui/spinner/spinner.css @@ -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); + } +} diff --git a/src/app/shared/ui/spinner/spinner.html b/src/app/shared/ui/spinner/spinner.html new file mode 100644 index 0000000..51c356c --- /dev/null +++ b/src/app/shared/ui/spinner/spinner.html @@ -0,0 +1,8 @@ + diff --git a/src/app/shared/ui/spinner/spinner.ts b/src/app/shared/ui/spinner/spinner.ts new file mode 100644 index 0000000..b5f3b51 --- /dev/null +++ b/src/app/shared/ui/spinner/spinner.ts @@ -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'); +} diff --git a/src/app/shared/ui/toast/toast.css b/src/app/shared/ui/toast/toast.css new file mode 100644 index 0000000..5f2a172 --- /dev/null +++ b/src/app/shared/ui/toast/toast.css @@ -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; + } +} diff --git a/src/app/shared/ui/toast/toast.html b/src/app/shared/ui/toast/toast.html new file mode 100644 index 0000000..4a070e4 --- /dev/null +++ b/src/app/shared/ui/toast/toast.html @@ -0,0 +1,51 @@ +
+ @for (notification of notifications(); track notification.id) { + + } +
diff --git a/src/app/shared/ui/toast/toast.ts b/src/app/shared/ui/toast/toast.ts new file mode 100644 index 0000000..9ff19b3 --- /dev/null +++ b/src/app/shared/ui/toast/toast.ts @@ -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); + } +} diff --git a/src/app/shared/utils/date.util.ts b/src/app/shared/utils/date.util.ts new file mode 100644 index 0000000..476bb79 --- /dev/null +++ b/src/app/shared/utils/date.util.ts @@ -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`; +} diff --git a/src/app/shared/utils/form-validators.util.ts b/src/app/shared/utils/form-validators.util.ts new file mode 100644 index 0000000..fbb8b77 --- /dev/null +++ b/src/app/shared/utils/form-validators.util.ts @@ -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 } }; +} diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts new file mode 100644 index 0000000..1b45274 --- /dev/null +++ b/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: 'http://localhost:3000/api', +}; diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 0000000..266e036 --- /dev/null +++ b/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiUrl: 'https://api.emi.challenge.berand97.dev/api', +}; diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..685b0cd --- /dev/null +++ b/src/index.html @@ -0,0 +1,18 @@ + + + + + EMI Task Management + + + + + + + + + + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..10788dd --- /dev/null +++ b/src/main.ts @@ -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); + }); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..6abe527 --- /dev/null +++ b/src/styles.css @@ -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; +}