Initial commit: timeline-elements-ui library
Angular 19 component library for timelines and event tracking: timeline, Gantt chart, event cards, date markers, connectors, and custom event templates. Includes signal-based services, SCSS design tokens with dark mode, and TypeScript type definitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.angular/
|
||||
*.tgz
|
||||
194
README.md
Normal file
194
README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Timeline Elements UI
|
||||
|
||||
Angular 19 components for timeline, activity feed, audit trail, changelog, and Gantt chart displays powered by @sda/base-ui.
|
||||
|
||||
## Features
|
||||
|
||||
- **Timeline** — Vertical timeline with alternating layouts, grouping, and sorting
|
||||
- **Horizontal Timeline** — Scrollable horizontal timeline with nav arrows
|
||||
- **Activity Feed** — Social-style activity stream with avatars and load-more pagination
|
||||
- **Audit Trail** — Comprehensive audit log with search, filter, and expandable change diffs
|
||||
- **Changelog** — Version-based changelog with categorized entries and breaking change badges
|
||||
- **Gantt Chart** — Interactive Gantt chart with zoom levels, progress bars, and today line
|
||||
- **Signal API** — Built with Angular 19 signals (`input()`, `output()`, `computed()`, `signal()`)
|
||||
- **Standalone** — All components are standalone with OnPush change detection
|
||||
- **Themeable** — CSS custom properties with base-ui token fallbacks
|
||||
- **Dark Mode** — Full dark mode support via `prefers-color-scheme`
|
||||
- **Accessible** — ARIA labels, keyboard navigation, focus management
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @sda/timeline-elements-ui @sda/base-ui
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { TlTimelineComponent, type TlTimelineItem } from '@sda/timeline-elements-ui';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TlTimelineComponent],
|
||||
template: `
|
||||
<tl-timeline
|
||||
[items]="events"
|
||||
layout="left"
|
||||
[groupByDate]="true"
|
||||
(itemClick)="onItemClick($event)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
export class AppComponent {
|
||||
events: TlTimelineItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Tenancy started',
|
||||
description: 'John Doe moved in',
|
||||
date: new Date('2024-01-01'),
|
||||
icon: 'home',
|
||||
status: 'completed'
|
||||
}
|
||||
];
|
||||
|
||||
onItemClick(event: any) {
|
||||
console.log('Clicked:', event.item);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### `tl-timeline`
|
||||
Vertical timeline with markers and connectors.
|
||||
|
||||
**Inputs:**
|
||||
- `items` — Timeline items (required)
|
||||
- `layout` — 'left' | 'right' | 'alternating'
|
||||
- `markerSize` — 'sm' | 'md' | 'lg'
|
||||
- `connectorStyle` — 'solid' | 'dashed' | 'dotted'
|
||||
- `sortDirection` — 'asc' | 'desc'
|
||||
- `groupByDate` — Group items by date labels
|
||||
- `animate` — Animate item entry
|
||||
- `loading` — Show skeleton loaders
|
||||
- `emptyMessage` — Message when no items
|
||||
|
||||
**Outputs:**
|
||||
- `itemClick` — Emits when item is clicked
|
||||
- `itemHover` — Emits when item is hovered
|
||||
|
||||
### `tl-horizontal-timeline`
|
||||
Horizontal scrollable timeline.
|
||||
|
||||
**Inputs:**
|
||||
- `items` — Timeline items (required)
|
||||
- `markerSize`, `connectorStyle`, `sortDirection` — Same as `tl-timeline`
|
||||
- `scrollable`, `showNavArrows`, `loading`, `emptyMessage`
|
||||
|
||||
**Outputs:**
|
||||
- `itemClick`
|
||||
|
||||
### `tl-activity-feed`
|
||||
Activity stream with avatars and pagination.
|
||||
|
||||
**Inputs:**
|
||||
- `items` — Activity items (required)
|
||||
- `pageSize` — Items per page
|
||||
- `showLoadMore`, `showUnreadIndicator`, `groupByDate`, `loading`, `emptyMessage`
|
||||
|
||||
**Outputs:**
|
||||
- `activityClick`, `actorClick`, `loadMore`
|
||||
|
||||
### `tl-audit-trail`
|
||||
Audit log with search, filter, and expandable diffs.
|
||||
|
||||
**Inputs:**
|
||||
- `entries` — Audit entries (required)
|
||||
- `pageSize`, `searchable`, `filterable`, `expandable`, `showSeverityIcons`, `loading`, `emptyMessage`, `serverSide`
|
||||
|
||||
**Outputs:**
|
||||
- `entryClick`, `filterChange`, `pageChange`
|
||||
|
||||
### `tl-changelog`
|
||||
Version-based changelog.
|
||||
|
||||
**Inputs:**
|
||||
- `versions` — Changelog versions (required)
|
||||
- `collapsible`, `expandLatest`, `showCategoryBadges`, `showBreakingBadge`, `loading`, `emptyMessage`
|
||||
|
||||
**Outputs:**
|
||||
- `versionClick`, `entryClick`
|
||||
|
||||
### `tl-gantt-chart`
|
||||
Interactive Gantt chart.
|
||||
|
||||
**Inputs:**
|
||||
- `tasks` — Gantt tasks (required)
|
||||
- `dependencies`, `zoomLevel`, `rowHeight`, `showDependencies`, `showProgress`, `showTodayLine`, `loading`, `emptyMessage`
|
||||
|
||||
**Outputs:**
|
||||
- `taskClick`, `taskResize`, `taskMove`, `zoomChange`, `scroll`
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
import { provideTimelineConfig } from '@sda/timeline-elements-ui';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideTimelineConfig({
|
||||
locale: 'en-US',
|
||||
relativeTime: true,
|
||||
animate: true,
|
||||
timeline: {
|
||||
layout: 'left',
|
||||
markerSize: 'md',
|
||||
connectorStyle: 'solid',
|
||||
sortDirection: 'desc',
|
||||
groupByDate: false,
|
||||
},
|
||||
activity: {
|
||||
pageSize: 20,
|
||||
showUnreadIndicator: true,
|
||||
groupByDate: true,
|
||||
},
|
||||
audit: {
|
||||
pageSize: 25,
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
expandable: true,
|
||||
showSeverityIcons: true,
|
||||
},
|
||||
gantt: {
|
||||
zoomLevel: 'week',
|
||||
rowHeight: 40,
|
||||
showDependencies: true,
|
||||
showProgress: true,
|
||||
showTodayLine: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
All components use CSS custom properties with base-ui token fallbacks:
|
||||
|
||||
```scss
|
||||
:root {
|
||||
--tl-bg: var(--color-bg-primary, #ffffff);
|
||||
--tl-text: var(--color-text-primary, #111827);
|
||||
--tl-border: var(--color-border-primary, #e5e7eb);
|
||||
--tl-marker-bg: var(--color-primary-500, #3b82f6);
|
||||
--tl-status-completed: #10b981;
|
||||
--tl-status-active: #3b82f6;
|
||||
--tl-status-error: #ef4444;
|
||||
// ... more tokens
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
8
build-for-dev.sh
Executable file
8
build-for-dev.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Building @sda/timeline-elements-ui for local development..."
|
||||
npm run build
|
||||
cd dist
|
||||
npm pack
|
||||
echo "Done! Package is ready in dist/"
|
||||
12
ng-package.json
Normal file
12
ng-package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
|
||||
"lib": {
|
||||
"entryFile": "src/index.ts",
|
||||
"styleIncludePaths": ["src/styles"]
|
||||
},
|
||||
"dest": "dist",
|
||||
"deleteDestPath": true,
|
||||
"assets": [
|
||||
"src/styles/**/*.scss"
|
||||
]
|
||||
}
|
||||
3785
package-lock.json
generated
Normal file
3785
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@sda/timeline-elements-ui",
|
||||
"version": "0.1.0",
|
||||
"description": "Angular components for timeline, activity feed, audit trail, changelog, and Gantt chart displays powered by @sda/base-ui",
|
||||
"keywords": [
|
||||
"angular",
|
||||
"timeline",
|
||||
"activity",
|
||||
"audit",
|
||||
"changelog",
|
||||
"gantt",
|
||||
"components",
|
||||
"ui"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.sky-ai.com/ui-core-design/timeline-elements-ui.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "ng-packagr -p ng-package.json",
|
||||
"build:dev": "./build-for-dev.sh"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^19.0.0",
|
||||
"@angular/core": "^19.0.0",
|
||||
"@sda/base-ui": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/common": "^19.1.0",
|
||||
"@angular/compiler": "^19.1.0",
|
||||
"@angular/compiler-cli": "^19.1.0",
|
||||
"@angular/core": "^19.1.0",
|
||||
"@angular/forms": "^19.1.0",
|
||||
"@sda/base-ui": "file:../../sda-frontend/libs/base-ui/dist",
|
||||
"ng-packagr": "^19.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
15
src/components/index.ts
Normal file
15
src/components/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Leaf components
|
||||
export * from './tl-timeline-toolbar';
|
||||
export * from './tl-timeline-item';
|
||||
export * from './tl-activity-item';
|
||||
export * from './tl-audit-entry';
|
||||
export * from './tl-changelog-version';
|
||||
export * from './tl-gantt-row';
|
||||
|
||||
// Container components
|
||||
export * from './tl-timeline';
|
||||
export * from './tl-horizontal-timeline';
|
||||
export * from './tl-activity-feed';
|
||||
export * from './tl-audit-trail';
|
||||
export * from './tl-changelog';
|
||||
export * from './tl-gantt-chart';
|
||||
1
src/components/tl-activity-feed/index.ts
Normal file
1
src/components/tl-activity-feed/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-activity-feed.component';
|
||||
@@ -0,0 +1,59 @@
|
||||
<div class="tl-activity-feed">
|
||||
@if (loading()) {
|
||||
<div class="tl-activity-feed__loading">
|
||||
@for (i of getLoadingItems(); track i) {
|
||||
<div class="tl-activity-feed__skeleton">
|
||||
<ui-skeleton width="36px" height="36px" variant="circle" />
|
||||
<div class="tl-activity-feed__skeleton-content">
|
||||
<ui-skeleton width="80%" height="16px" />
|
||||
<ui-skeleton width="60%" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (items().length === 0) {
|
||||
<div class="tl-activity-feed__empty">
|
||||
<p>{{ emptyMessage() }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (groupByDate() && groupedItems()) {
|
||||
@for (group of groupedItems(); track group.label) {
|
||||
<div class="tl-activity-feed__group">
|
||||
<h4 class="tl-activity-feed__group-label">{{ group.label }}</h4>
|
||||
<div class="tl-activity-feed__items">
|
||||
@for (item of group.items; track item.id) {
|
||||
<tl-activity-item
|
||||
[item]="item"
|
||||
[showUnreadIndicator]="showUnreadIndicator()"
|
||||
(activityClick)="onActivityClick($event)"
|
||||
(actorClick)="onActorClick($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="tl-activity-feed__items">
|
||||
@for (item of displayedItems(); track item.id) {
|
||||
<tl-activity-item
|
||||
[item]="item"
|
||||
[showUnreadIndicator]="showUnreadIndicator()"
|
||||
(activityClick)="onActivityClick($event)"
|
||||
(actorClick)="onActorClick($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showLoadMore() && hasMore()) {
|
||||
<div class="tl-activity-feed__load-more">
|
||||
<ui-button
|
||||
|
||||
(click)="onLoadMore()"
|
||||
>
|
||||
Load More
|
||||
</ui-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
.tl-activity-feed {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
transition: all var(--transition-normal, 200ms ease);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--tl-activity-gap);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
&__skeleton-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs, 0.5rem);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: var(--spacing-xl, 2rem);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
&__group {
|
||||
&:not(:last-child) {
|
||||
border-bottom: 2px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
&__group-label {
|
||||
margin: 0;
|
||||
padding: var(--spacing-md, 1rem) var(--spacing-md, 1rem) var(--spacing-sm, 0.75rem);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__load-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md, 1rem);
|
||||
border-top: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent, SkeletonComponent } from '@sda/base-ui';
|
||||
import { TlActivityItemComponent } from '../tl-activity-item';
|
||||
import type { TlActivityItem } from '../../types/activity.types';
|
||||
import type { TlActivityClickEvent, TlActorClickEvent, TlActivityLoadMoreEvent } from '../../types/event.types';
|
||||
import { groupByDate } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-activity-feed',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ButtonComponent, SkeletonComponent, TlActivityItemComponent],
|
||||
templateUrl: './tl-activity-feed.component.html',
|
||||
styleUrl: './tl-activity-feed.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TlActivityFeedComponent {
|
||||
readonly items = input.required<TlActivityItem[]>();
|
||||
readonly pageSize = input(20);
|
||||
readonly showLoadMore = input(true);
|
||||
readonly showUnreadIndicator = input(true);
|
||||
readonly loading = input(false);
|
||||
readonly emptyMessage = input('No activity');
|
||||
readonly groupByDate = input(true);
|
||||
|
||||
readonly activityClick = output<TlActivityClickEvent>();
|
||||
readonly actorClick = output<TlActorClickEvent>();
|
||||
readonly loadMore = output<TlActivityLoadMoreEvent>();
|
||||
|
||||
protected readonly displayCount = signal(20);
|
||||
|
||||
protected readonly displayedItems = computed(() => {
|
||||
const count = this.displayCount();
|
||||
const pageSize = this.pageSize();
|
||||
const actualCount = Math.max(count, pageSize);
|
||||
return this.items().slice(0, actualCount);
|
||||
});
|
||||
|
||||
protected readonly hasMore = computed(() => {
|
||||
return this.displayedItems().length < this.items().length;
|
||||
});
|
||||
|
||||
protected readonly groupedItems = computed(() => {
|
||||
if (!this.groupByDate()) return null;
|
||||
return groupByDate(this.displayedItems());
|
||||
});
|
||||
|
||||
protected onLoadMore(): void {
|
||||
const currentCount = this.displayedItems().length;
|
||||
const pageSize = this.pageSize();
|
||||
this.displayCount.update(c => c + pageSize);
|
||||
this.loadMore.emit({ currentCount, pageSize });
|
||||
}
|
||||
|
||||
protected onActivityClick(event: TlActivityClickEvent): void {
|
||||
this.activityClick.emit(event);
|
||||
}
|
||||
|
||||
protected onActorClick(event: TlActorClickEvent): void {
|
||||
this.actorClick.emit(event);
|
||||
}
|
||||
|
||||
protected getLoadingItems(): number[] {
|
||||
return Array.from({ length: 5 }, (_, i) => i);
|
||||
}
|
||||
}
|
||||
1
src/components/tl-activity-item/index.ts
Normal file
1
src/components/tl-activity-item/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-activity-item.component';
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="tl-activity-item" (click)="onActivityClick()">
|
||||
@if (showUnreadIndicator() && isUnread()) {
|
||||
<div class="tl-activity-item__unread-dot"></div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="tl-activity-item__avatar"
|
||||
(click)="onActorClick($event)"
|
||||
>
|
||||
@if (item().actor.avatar) {
|
||||
<img [src]="item().actor.avatar" [alt]="item().actor.name" />
|
||||
} @else {
|
||||
{{ item().actor.initials || item().actor.name.substring(0, 2) }}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tl-activity-item__content">
|
||||
<div class="tl-activity-item__header">
|
||||
<span class="tl-activity-item__actor" (click)="onActorClick($event)">
|
||||
{{ item().actor.name }}
|
||||
</span>
|
||||
<span class="tl-activity-item__action">{{ item().action }}</span>
|
||||
@if (item().target) {
|
||||
<span class="tl-activity-item__target">{{ item().target }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item().description) {
|
||||
<p class="tl-activity-item__description">{{ item().description }}</p>
|
||||
}
|
||||
|
||||
<time class="tl-activity-item__time">{{ formattedTime() }}</time>
|
||||
</div>
|
||||
|
||||
<div class="tl-activity-item__icon">
|
||||
<ui-icon [name]="actionIcon()" />
|
||||
</div>
|
||||
</div>
|
||||
108
src/components/tl-activity-item/tl-activity-item.component.scss
Normal file
108
src/components/tl-activity-item/tl-activity-item.component.scss
Normal file
@@ -0,0 +1,108 @@
|
||||
.tl-activity-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: var(--spacing-sm, 0.75rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal, 200ms ease);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
&--unread {
|
||||
background: var(--color-bg-selected, #eff6ff);
|
||||
}
|
||||
|
||||
&__unread-dot {
|
||||
position: absolute;
|
||||
left: var(--spacing-xs, 0.5rem);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--tl-activity-unread-dot);
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: var(--tl-activity-avatar-size, 36px);
|
||||
height: var(--tl-activity-avatar-size, 36px);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-tertiary, #e5e7eb);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__actor {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
&__target {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #111827);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0.25rem 0 0.25rem;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||
import { IconComponent, TooltipDirective } from '@sda/base-ui';
|
||||
import type { TlActivityItem } from '../../types/activity.types';
|
||||
import type { TlActivityClickEvent, TlActorClickEvent } from '../../types/event.types';
|
||||
import { formatSmartTime } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-activity-item',
|
||||
standalone: true,
|
||||
imports: [IconComponent, TooltipDirective],
|
||||
templateUrl: './tl-activity-item.component.html',
|
||||
styleUrl: './tl-activity-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.tl-activity-item--unread]': 'isUnread()',
|
||||
},
|
||||
})
|
||||
export class TlActivityItemComponent {
|
||||
readonly item = input.required<TlActivityItem>();
|
||||
readonly showUnreadIndicator = input(true);
|
||||
|
||||
readonly activityClick = output<TlActivityClickEvent>();
|
||||
readonly actorClick = output<TlActorClickEvent>();
|
||||
|
||||
protected readonly formattedTime = computed(() => formatSmartTime(this.item().date));
|
||||
|
||||
protected readonly actionIcon = computed(() => {
|
||||
const item = this.item();
|
||||
if (item.icon) return item.icon;
|
||||
switch (item.type) {
|
||||
case 'create': return 'plus';
|
||||
case 'update': return 'edit';
|
||||
case 'delete': return 'trash';
|
||||
case 'comment': return 'message';
|
||||
case 'assign': return 'user';
|
||||
case 'status': return 'activity';
|
||||
case 'upload': return 'upload';
|
||||
case 'share': return 'share';
|
||||
case 'system': return 'settings';
|
||||
default: return 'activity';
|
||||
}
|
||||
});
|
||||
|
||||
protected readonly isUnread = computed(() => this.item().unread ?? false);
|
||||
|
||||
protected onActivityClick(): void {
|
||||
this.activityClick.emit({ item: this.item() });
|
||||
}
|
||||
|
||||
protected onActorClick(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.actorClick.emit({ actor: this.item().actor });
|
||||
}
|
||||
}
|
||||
1
src/components/tl-audit-entry/index.ts
Normal file
1
src/components/tl-audit-entry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-audit-entry.component';
|
||||
71
src/components/tl-audit-entry/tl-audit-entry.component.html
Normal file
71
src/components/tl-audit-entry/tl-audit-entry.component.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<div class="tl-audit-entry" (click)="onEntryClick()">
|
||||
<div class="tl-audit-entry__main">
|
||||
@if (showSeverityIcon()) {
|
||||
<div class="tl-audit-entry__icon" [style.color]="severityColor()">
|
||||
<ui-icon [name]="severityIcon()" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="tl-audit-entry__content">
|
||||
<div class="tl-audit-entry__header">
|
||||
<span class="tl-audit-entry__action">{{ entry().action }}</span>
|
||||
<ui-badge>{{ entry().category }}</ui-badge>
|
||||
<ui-badge [style.background-color]="severityColor()">
|
||||
{{ entry().severity }}
|
||||
</ui-badge>
|
||||
</div>
|
||||
|
||||
<div class="tl-audit-entry__meta">
|
||||
<span class="tl-audit-entry__user">{{ entry().user }}</span>
|
||||
<span class="tl-audit-entry__separator">•</span>
|
||||
<time class="tl-audit-entry__time">{{ formattedTime() }}</time>
|
||||
@if (entry().resource) {
|
||||
<span class="tl-audit-entry__separator">•</span>
|
||||
<span class="tl-audit-entry__resource">{{ entry().resource }}</span>
|
||||
}
|
||||
@if (entry().ipAddress) {
|
||||
<span class="tl-audit-entry__separator">•</span>
|
||||
<span class="tl-audit-entry__ip">{{ entry().ipAddress }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (entry().description) {
|
||||
<p class="tl-audit-entry__description">{{ entry().description }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (expandable() && hasChanges()) {
|
||||
<div class="tl-audit-entry__expand">
|
||||
<ui-icon [name]="isExpanded() ? 'chevron-up' : 'chevron-down'" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isExpanded() && hasChanges()) {
|
||||
<div class="tl-audit-entry__changes">
|
||||
<h5 class="tl-audit-entry__changes-title">Changes</h5>
|
||||
<table class="tl-audit-entry__changes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Old Value</th>
|
||||
<th>New Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (change of entry().changes; track $index) {
|
||||
<tr>
|
||||
<td class="tl-audit-entry__field">{{ change.field }}</td>
|
||||
<td class="tl-audit-entry__old-value">
|
||||
{{ change.oldValue ?? '(empty)' }}
|
||||
</td>
|
||||
<td class="tl-audit-entry__new-value">
|
||||
{{ change.newValue ?? '(empty)' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
130
src/components/tl-audit-entry/tl-audit-entry.component.scss
Normal file
130
src/components/tl-audit-entry/tl-audit-entry.component.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
.tl-audit-entry {
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
transition: background var(--transition-normal, 200ms ease);
|
||||
|
||||
&--expandable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs, 0.5rem);
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xs, 0.5rem);
|
||||
}
|
||||
|
||||
&__action {
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs, 0.5rem);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
&__user {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
&__separator {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: var(--spacing-sm, 0.75rem) 0 0;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__expand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
transition: transform var(--transition-normal, 200ms ease);
|
||||
}
|
||||
|
||||
&--expanded &__expand {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
&__changes {
|
||||
padding: 0 var(--spacing-md, 1rem) var(--spacing-md, 1rem);
|
||||
padding-left: calc(var(--spacing-md, 1rem) * 3 + 20px);
|
||||
}
|
||||
|
||||
&__changes-title {
|
||||
margin: 0 0 var(--spacing-sm, 0.75rem);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
}
|
||||
|
||||
&__changes-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
|
||||
th {
|
||||
padding: var(--spacing-xs, 0.5rem) var(--spacing-sm, 0.75rem);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--spacing-xs, 0.5rem) var(--spacing-sm, 0.75rem);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__field {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #111827);
|
||||
}
|
||||
|
||||
&__old-value {
|
||||
color: var(--tl-status-error);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&__new-value {
|
||||
color: var(--tl-change-added);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
62
src/components/tl-audit-entry/tl-audit-entry.component.ts
Normal file
62
src/components/tl-audit-entry/tl-audit-entry.component.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed, signal } from '@angular/core';
|
||||
import { IconComponent, BadgeComponent, TooltipDirective } from '@sda/base-ui';
|
||||
import type { TlAuditEntry } from '../../types/audit.types';
|
||||
import type { TlAuditEntryClickEvent } from '../../types/event.types';
|
||||
import { formatSmartTime } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-audit-entry',
|
||||
standalone: true,
|
||||
imports: [IconComponent, BadgeComponent, TooltipDirective],
|
||||
templateUrl: './tl-audit-entry.component.html',
|
||||
styleUrl: './tl-audit-entry.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.tl-audit-entry--expandable]': 'expandable()',
|
||||
'[class.tl-audit-entry--expanded]': 'isExpanded()',
|
||||
},
|
||||
})
|
||||
export class TlAuditEntryComponent {
|
||||
readonly entry = input.required<TlAuditEntry>();
|
||||
readonly expandable = input(true);
|
||||
readonly expanded = input(false);
|
||||
readonly showSeverityIcon = input(true);
|
||||
|
||||
readonly entryClick = output<TlAuditEntryClickEvent>();
|
||||
readonly expandToggle = output<boolean>();
|
||||
|
||||
protected readonly isExpanded = signal(false);
|
||||
|
||||
protected readonly formattedTime = computed(() => formatSmartTime(this.entry().date));
|
||||
|
||||
protected readonly severityIcon = computed(() => {
|
||||
switch (this.entry().severity) {
|
||||
case 'critical': return 'alert-circle';
|
||||
case 'error': return 'x-circle';
|
||||
case 'warning': return 'alert-triangle';
|
||||
case 'info': return 'info';
|
||||
case 'debug': return 'bug';
|
||||
default: return 'info';
|
||||
}
|
||||
});
|
||||
|
||||
protected readonly severityColor = computed(() => {
|
||||
return `var(--tl-severity-${this.entry().severity})`;
|
||||
});
|
||||
|
||||
protected readonly hasChanges = computed(() => {
|
||||
return (this.entry().changes?.length ?? 0) > 0;
|
||||
});
|
||||
|
||||
protected onEntryClick(): void {
|
||||
this.entryClick.emit({ entry: this.entry() });
|
||||
if (this.expandable() && this.hasChanges()) {
|
||||
this.toggleExpand();
|
||||
}
|
||||
}
|
||||
|
||||
protected toggleExpand(): void {
|
||||
this.isExpanded.update(v => !v);
|
||||
this.expandToggle.emit(this.isExpanded());
|
||||
}
|
||||
}
|
||||
1
src/components/tl-audit-trail/index.ts
Normal file
1
src/components/tl-audit-trail/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-audit-trail.component';
|
||||
50
src/components/tl-audit-trail/tl-audit-trail.component.html
Normal file
50
src/components/tl-audit-trail/tl-audit-trail.component.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<div class="tl-audit-trail">
|
||||
@if (searchable() || filterable()) {
|
||||
<tl-timeline-toolbar
|
||||
[searchable]="searchable()"
|
||||
searchPlaceholder="Search audit logs..."
|
||||
[showFilter]="filterable()"
|
||||
[filterCount]="activeFilterCount()"
|
||||
(searchChange)="onSearchChange($event)"
|
||||
(clearFilters)="onClearFilters()"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="tl-audit-trail__content">
|
||||
@if (loading()) {
|
||||
<div class="tl-audit-trail__loading">
|
||||
@for (i of getLoadingItems(); track i) {
|
||||
<div class="tl-audit-trail__skeleton">
|
||||
<ui-skeleton width="20px" height="20px" variant="circle" />
|
||||
<ui-skeleton width="100%" height="60px" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (pagedEntries().length === 0) {
|
||||
<div class="tl-audit-trail__empty">
|
||||
<ui-icon name="file-text" />
|
||||
<p>{{ emptyMessage() }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tl-audit-trail__entries">
|
||||
@for (entry of pagedEntries(); track entry.id) {
|
||||
<tl-audit-entry
|
||||
[entry]="entry"
|
||||
[expandable]="expandable()"
|
||||
[showSeverityIcon]="showSeverityIcons()"
|
||||
(entryClick)="onEntryClick($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (totalPages() > 1) {
|
||||
<div class="tl-audit-trail__pagination">
|
||||
<span class="tl-audit-trail__pagination-info">
|
||||
Page {{ currentPage() }} of {{ totalPages() }}
|
||||
({{ filteredEntries().length }} entries)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
59
src/components/tl-audit-trail/tl-audit-trail.component.scss
Normal file
59
src/components/tl-audit-trail/tl-audit-trail.component.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
.tl-audit-trail {
|
||||
&__content {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
transition: all var(--transition-normal, 200ms ease);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
padding: var(--spacing-xl, 2rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
&__entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md, 1rem);
|
||||
border-top: 2px solid var(--color-border-primary, #e5e7eb);
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
&__pagination-info {
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
}
|
||||
98
src/components/tl-audit-trail/tl-audit-trail.component.ts
Normal file
98
src/components/tl-audit-trail/tl-audit-trail.component.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent, IconComponent, BadgeComponent, SkeletonComponent } from '@sda/base-ui';
|
||||
import { TlAuditEntryComponent } from '../tl-audit-entry';
|
||||
import { TlTimelineToolbarComponent } from '../tl-timeline-toolbar';
|
||||
import { TimelineService } from '../../services/timeline.service';
|
||||
import type { TlAuditEntry } from '../../types/audit.types';
|
||||
import type { TlAuditEntryClickEvent, TlAuditFilterChangeEvent, TlAuditPageChangeEvent } from '../../types/event.types';
|
||||
import { filterAuditEntries } from '../../utils/filter.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-audit-trail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ButtonComponent,
|
||||
IconComponent,
|
||||
BadgeComponent,
|
||||
SkeletonComponent,
|
||||
TlAuditEntryComponent,
|
||||
TlTimelineToolbarComponent,
|
||||
],
|
||||
providers: [TimelineService],
|
||||
templateUrl: './tl-audit-trail.component.html',
|
||||
styleUrl: './tl-audit-trail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TlAuditTrailComponent {
|
||||
constructor(private readonly service: TimelineService) {}
|
||||
|
||||
readonly entries = input.required<TlAuditEntry[]>();
|
||||
readonly pageSize = input(25);
|
||||
readonly searchable = input(true);
|
||||
readonly filterable = input(true);
|
||||
readonly expandable = input(true);
|
||||
readonly showSeverityIcons = input(true);
|
||||
readonly loading = input(false);
|
||||
readonly emptyMessage = input('No audit entries');
|
||||
readonly serverSide = input(false);
|
||||
|
||||
readonly entryClick = output<TlAuditEntryClickEvent>();
|
||||
readonly filterChange = output<TlAuditFilterChangeEvent>();
|
||||
readonly pageChange = output<TlAuditPageChangeEvent>();
|
||||
|
||||
protected readonly currentPage = computed(() => 1); // Simplified for now
|
||||
|
||||
protected readonly filteredEntries = computed(() => {
|
||||
if (this.serverSide()) return this.entries();
|
||||
|
||||
return filterAuditEntries(this.entries(), {
|
||||
searchTerm: this.service.searchTerm(),
|
||||
categories: this.service.categoryFilter(),
|
||||
severities: this.service.severityFilter(),
|
||||
dateRange: this.service.dateRangeFilter(),
|
||||
});
|
||||
});
|
||||
|
||||
protected readonly pagedEntries = computed(() => {
|
||||
const filtered = this.filteredEntries();
|
||||
const pageSize = this.pageSize();
|
||||
const page = this.currentPage();
|
||||
const start = (page - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
});
|
||||
|
||||
protected readonly totalPages = computed(() => {
|
||||
return Math.ceil(this.filteredEntries().length / this.pageSize());
|
||||
});
|
||||
|
||||
protected readonly activeFilterCount = computed(() => this.service.activeFilterCount());
|
||||
|
||||
protected onSearchChange(term: string): void {
|
||||
this.service.setSearch(term);
|
||||
this.emitFilterChange();
|
||||
}
|
||||
|
||||
protected onClearFilters(): void {
|
||||
this.service.clearAll();
|
||||
this.emitFilterChange();
|
||||
}
|
||||
|
||||
protected onEntryClick(event: TlAuditEntryClickEvent): void {
|
||||
this.entryClick.emit(event);
|
||||
}
|
||||
|
||||
private emitFilterChange(): void {
|
||||
this.filterChange.emit({
|
||||
searchTerm: this.service.searchTerm(),
|
||||
categories: this.service.categoryFilter(),
|
||||
severities: this.service.severityFilter(),
|
||||
dateRange: this.service.dateRangeFilter(),
|
||||
});
|
||||
}
|
||||
|
||||
protected getLoadingItems(): number[] {
|
||||
return Array.from({ length: 5 }, (_, i) => i);
|
||||
}
|
||||
}
|
||||
1
src/components/tl-changelog-version/index.ts
Normal file
1
src/components/tl-changelog-version/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-changelog-version.component';
|
||||
@@ -0,0 +1,73 @@
|
||||
<div class="tl-changelog-version">
|
||||
<div class="tl-changelog-version__header" (click)="onVersionClick()">
|
||||
<div class="tl-changelog-version__title-group">
|
||||
<h3 class="tl-changelog-version__version">{{ version().version }}</h3>
|
||||
@if (version().title) {
|
||||
<span class="tl-changelog-version__title">{{ version().title }}</span>
|
||||
}
|
||||
</div>
|
||||
<time class="tl-changelog-version__date">{{ formattedDate() }}</time>
|
||||
@if (collapsible()) {
|
||||
<ui-button variant="ghost" (click)="toggleExpand()">
|
||||
<ui-icon [name]="isExpanded() ? 'chevron-up' : 'chevron-down'" />
|
||||
</ui-button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (version().description) {
|
||||
<p class="tl-changelog-version__description">{{ version().description }}</p>
|
||||
}
|
||||
|
||||
@if (isExpanded()) {
|
||||
<div class="tl-changelog-version__entries">
|
||||
@for (group of entriesByCategory(); track group.category) {
|
||||
<div class="tl-changelog-version__category">
|
||||
<div class="tl-changelog-version__category-header">
|
||||
<ui-icon
|
||||
[name]="categoryIcon()(group.category)"
|
||||
|
||||
[style.color]="categoryColor()(group.category)"
|
||||
/>
|
||||
<h4 class="tl-changelog-version__category-title">
|
||||
{{ group.category | titlecase }}
|
||||
</h4>
|
||||
@if (showCategoryBadges()) {
|
||||
<ui-badge
|
||||
|
||||
[style.background-color]="categoryColor()(group.category)"
|
||||
>
|
||||
{{ group.entries.length }}
|
||||
</ui-badge>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ul class="tl-changelog-version__list">
|
||||
@for (entry of group.entries; track $index) {
|
||||
<li
|
||||
class="tl-changelog-version__entry"
|
||||
(click)="onEntryClick(entry)"
|
||||
>
|
||||
<span class="tl-changelog-version__entry-text">
|
||||
{{ entry.description }}
|
||||
</span>
|
||||
@if (showBreakingBadge() && entry.breaking) {
|
||||
<ui-badge>BREAKING</ui-badge>
|
||||
}
|
||||
@if (entry.reference) {
|
||||
<a
|
||||
class="tl-changelog-version__reference"
|
||||
[href]="entry.reference"
|
||||
target="_blank"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ui-icon name="external-link" />
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
.tl-changelog-version {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
transition: all var(--transition-normal, 200ms ease);
|
||||
margin-bottom: var(--spacing-lg, 1.5rem);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-md, 1rem);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .tl-changelog-version__version {
|
||||
color: var(--color-border-focus, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
&__title-group {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-sm, 0.75rem);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__version {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #111827);
|
||||
transition: color var(--transition-normal, 200ms ease);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0 0 var(--spacing-md, 1rem);
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg, 1.5rem);
|
||||
}
|
||||
|
||||
&__category {
|
||||
&:not(:last-child) {
|
||||
padding-bottom: var(--spacing-md, 1rem);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
&__category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm, 0.75rem);
|
||||
margin-bottom: var(--spacing-sm, 0.75rem);
|
||||
}
|
||||
|
||||
&__category-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs, 0.5rem);
|
||||
}
|
||||
|
||||
&__entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm, 0.75rem);
|
||||
padding: var(--spacing-xs, 0.5rem) 0;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
line-height: 1.5;
|
||||
|
||||
&::before {
|
||||
content: '•';
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-weight: bold;
|
||||
margin-right: var(--spacing-xs, 0.5rem);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary, #111827);
|
||||
}
|
||||
}
|
||||
|
||||
&__entry-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__reference {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-border-focus, #3b82f6);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&--collapsible &__header {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed, signal } from '@angular/core';
|
||||
import { TitleCasePipe } from '@angular/common';
|
||||
import { BadgeComponent, IconComponent, ButtonComponent } from '@sda/base-ui';
|
||||
import type { TlChangelogVersion, TlChangeCategory } from '../../types/changelog.types';
|
||||
import type { TlVersionClickEvent, TlChangelogEntryClickEvent } from '../../types/event.types';
|
||||
import { formatDate } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-changelog-version',
|
||||
standalone: true,
|
||||
imports: [BadgeComponent, IconComponent, ButtonComponent, TitleCasePipe],
|
||||
templateUrl: './tl-changelog-version.component.html',
|
||||
styleUrl: './tl-changelog-version.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.tl-changelog-version--collapsible]': 'collapsible()',
|
||||
'[class.tl-changelog-version--expanded]': 'isExpanded()',
|
||||
},
|
||||
})
|
||||
export class TlChangelogVersionComponent {
|
||||
readonly version = input.required<TlChangelogVersion>();
|
||||
readonly collapsible = input(false);
|
||||
readonly expanded = input(true);
|
||||
readonly showCategoryBadges = input(true);
|
||||
readonly showBreakingBadge = input(true);
|
||||
|
||||
readonly versionClick = output<TlVersionClickEvent>();
|
||||
readonly entryClick = output<TlChangelogEntryClickEvent>();
|
||||
readonly expandToggle = output<boolean>();
|
||||
|
||||
protected readonly isExpanded = signal(true);
|
||||
|
||||
protected readonly formattedDate = computed(() => formatDate(this.version().date));
|
||||
|
||||
protected readonly entriesByCategory = computed(() => {
|
||||
const entries = this.version().entries;
|
||||
const categories: TlChangeCategory[] = ['added', 'changed', 'fixed', 'removed', 'deprecated', 'security'];
|
||||
|
||||
return categories
|
||||
.map(category => ({
|
||||
category,
|
||||
entries: entries.filter(e => e.category === category),
|
||||
}))
|
||||
.filter(group => group.entries.length > 0);
|
||||
});
|
||||
|
||||
protected readonly categoryColor = computed(() => {
|
||||
return (category: TlChangeCategory): string => {
|
||||
return `var(--tl-change-${category})`;
|
||||
};
|
||||
});
|
||||
|
||||
protected readonly categoryIcon = computed(() => {
|
||||
return (category: TlChangeCategory): string => {
|
||||
switch (category) {
|
||||
case 'added': return 'plus';
|
||||
case 'changed': return 'edit';
|
||||
case 'fixed': return 'check';
|
||||
case 'removed': return 'minus';
|
||||
case 'deprecated': return 'alert-triangle';
|
||||
case 'security': return 'shield';
|
||||
default: return 'circle';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
protected onVersionClick(): void {
|
||||
this.versionClick.emit({ version: this.version() });
|
||||
if (this.collapsible()) {
|
||||
this.toggleExpand();
|
||||
}
|
||||
}
|
||||
|
||||
protected toggleExpand(): void {
|
||||
this.isExpanded.update(v => !v);
|
||||
this.expandToggle.emit(this.isExpanded());
|
||||
}
|
||||
|
||||
protected onEntryClick(entry: any): void {
|
||||
this.entryClick.emit({ entry, version: this.version() });
|
||||
}
|
||||
}
|
||||
1
src/components/tl-changelog/index.ts
Normal file
1
src/components/tl-changelog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-changelog.component';
|
||||
31
src/components/tl-changelog/tl-changelog.component.html
Normal file
31
src/components/tl-changelog/tl-changelog.component.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<div class="tl-changelog">
|
||||
@if (loading()) {
|
||||
<div class="tl-changelog__loading">
|
||||
@for (i of getLoadingItems(); track i) {
|
||||
<div class="tl-changelog__skeleton">
|
||||
<ui-skeleton width="150px" height="32px" />
|
||||
<ui-skeleton width="100%" height="120px" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (versions().length === 0) {
|
||||
<div class="tl-changelog__empty">
|
||||
<p>{{ emptyMessage() }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tl-changelog__versions">
|
||||
@for (version of versions(); track version.version) {
|
||||
<tl-changelog-version
|
||||
[version]="version"
|
||||
[collapsible]="collapsible()"
|
||||
[expanded]="isExpanded(version.version)"
|
||||
[showCategoryBadges]="showCategoryBadges()"
|
||||
[showBreakingBadge]="showBreakingBadge()"
|
||||
(versionClick)="onVersionClick($event)"
|
||||
(entryClick)="onEntryClick($event)"
|
||||
(expandToggle)="onExpandToggle(version.version, $event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
39
src/components/tl-changelog/tl-changelog.component.scss
Normal file
39
src/components/tl-changelog/tl-changelog.component.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
.tl-changelog {
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg, 1.5rem);
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
transition: all var(--transition-normal, 200ms ease);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
transition: all var(--transition-normal, 200ms ease);
|
||||
padding: var(--spacing-xl, 2rem);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
&__versions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
59
src/components/tl-changelog/tl-changelog.component.ts
Normal file
59
src/components/tl-changelog/tl-changelog.component.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SkeletonComponent } from '@sda/base-ui';
|
||||
import { TlChangelogVersionComponent } from '../tl-changelog-version';
|
||||
import type { TlChangelogVersion } from '../../types/changelog.types';
|
||||
import type { TlVersionClickEvent, TlChangelogEntryClickEvent } from '../../types/event.types';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-changelog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SkeletonComponent, TlChangelogVersionComponent],
|
||||
templateUrl: './tl-changelog.component.html',
|
||||
styleUrl: './tl-changelog.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TlChangelogComponent {
|
||||
readonly versions = input.required<TlChangelogVersion[]>();
|
||||
readonly collapsible = input(false);
|
||||
readonly expandLatest = input(true);
|
||||
readonly showCategoryBadges = input(true);
|
||||
readonly showBreakingBadge = input(true);
|
||||
readonly loading = input(false);
|
||||
readonly emptyMessage = input('No changelog entries');
|
||||
|
||||
readonly versionClick = output<TlVersionClickEvent>();
|
||||
readonly entryClick = output<TlChangelogEntryClickEvent>();
|
||||
|
||||
protected readonly expandedVersions = signal<Set<string>>(new Set());
|
||||
|
||||
protected isExpanded(version: string): boolean {
|
||||
if (!this.collapsible()) return true;
|
||||
if (this.expandLatest() && this.versions()[0]?.version === version) return true;
|
||||
return this.expandedVersions().has(version);
|
||||
}
|
||||
|
||||
protected onVersionClick(event: TlVersionClickEvent): void {
|
||||
this.versionClick.emit(event);
|
||||
}
|
||||
|
||||
protected onEntryClick(event: TlChangelogEntryClickEvent): void {
|
||||
this.entryClick.emit(event);
|
||||
}
|
||||
|
||||
protected onExpandToggle(version: string, expanded: boolean): void {
|
||||
this.expandedVersions.update(set => {
|
||||
const newSet = new Set(set);
|
||||
if (expanded) {
|
||||
newSet.add(version);
|
||||
} else {
|
||||
newSet.delete(version);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
protected getLoadingItems(): number[] {
|
||||
return Array.from({ length: 3 }, (_, i) => i);
|
||||
}
|
||||
}
|
||||
1
src/components/tl-gantt-chart/index.ts
Normal file
1
src/components/tl-gantt-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-gantt-chart.component';
|
||||
73
src/components/tl-gantt-chart/tl-gantt-chart.component.html
Normal file
73
src/components/tl-gantt-chart/tl-gantt-chart.component.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<div class="tl-gantt-chart">
|
||||
<tl-timeline-toolbar
|
||||
[showZoom]="true"
|
||||
(zoomIn)="onZoomIn()"
|
||||
(zoomOut)="onZoomOut()"
|
||||
/>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="tl-gantt-chart__loading">
|
||||
@for (i of getLoadingItems(); track i) {
|
||||
<ui-skeleton width="100%" [height]="rowHeight() + 'px'" />
|
||||
}
|
||||
</div>
|
||||
} @else if (tasks().length === 0) {
|
||||
<div class="tl-gantt-chart__empty">
|
||||
<ui-icon name="calendar" />
|
||||
<p>{{ emptyMessage() }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tl-gantt-chart__container">
|
||||
<!-- Sidebar with task names -->
|
||||
<div class="tl-gantt-chart__sidebar">
|
||||
<div class="tl-gantt-chart__sidebar-header" [style.height.px]="rowHeight()">
|
||||
Task Name
|
||||
</div>
|
||||
@for (task of sortedTasks(); track task.id) {
|
||||
<div class="tl-gantt-chart__sidebar-row" [style.height.px]="rowHeight()">
|
||||
{{ task.name }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Chart area -->
|
||||
<div class="tl-gantt-chart__chart">
|
||||
<!-- Date axis -->
|
||||
<div class="tl-gantt-chart__axis" [style.width.px]="totalWidth()">
|
||||
@for (tick of dateAxis(); track tick.position) {
|
||||
<div
|
||||
class="tl-gantt-chart__tick"
|
||||
[class.tl-gantt-chart__tick--major]="tick.isMajor"
|
||||
[class.tl-gantt-chart__tick--weekend]="tick.isWeekend"
|
||||
[style.left.px]="tick.position"
|
||||
>
|
||||
{{ tick.label }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Task rows -->
|
||||
<div class="tl-gantt-chart__rows" [style.width.px]="totalWidth()">
|
||||
@for (task of sortedTasks(); track task.id; let idx = $index) {
|
||||
<tl-gantt-row
|
||||
[task]="task"
|
||||
[position]="taskPositions()[idx]"
|
||||
[showProgress]="showProgress()"
|
||||
[rowHeight]="rowHeight()"
|
||||
(taskClick)="onTaskClick($event)"
|
||||
(taskResize)="onTaskResize($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Today line -->
|
||||
@if (showTodayLine() && todayPosition() !== null) {
|
||||
<div
|
||||
class="tl-gantt-chart__today-line"
|
||||
[style.left.px]="todayPosition()"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
165
src/components/tl-gantt-chart/tl-gantt-chart.component.scss
Normal file
165
src/components/tl-gantt-chart/tl-gantt-chart.component.scss
Normal file
@@ -0,0 +1,165 @@
|
||||
.tl-gantt-chart {
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
padding: var(--spacing-2xl, 3rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: var(--tl-gantt-sidebar-width) 1fr;
|
||||
}
|
||||
|
||||
// ── Sidebar ──
|
||||
&__sidebar {
|
||||
border-right: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
}
|
||||
|
||||
&__sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--spacing-md, 1rem);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
&__sidebar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--spacing-md, 1rem);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #111827);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition-fast, 150ms ease);
|
||||
|
||||
&:nth-child(even) {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chart area ──
|
||||
&__chart {
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-primary, #e5e7eb);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date axis ──
|
||||
&__axis {
|
||||
position: relative;
|
||||
height: var(--tl-gantt-axis-height);
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
&__tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 0 var(--spacing-xs, 0.5rem) 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
border-left: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
|
||||
&--major {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
border-left: 1px solid var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
&--weekend {
|
||||
background: var(--tl-gantt-weekend-bg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rows ──
|
||||
&__rows {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
// ── Today line ──
|
||||
&__today-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--tl-gantt-today-line);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: 'Today';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-bg-primary, #ffffff);
|
||||
background: var(--tl-gantt-today-line);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/components/tl-gantt-chart/tl-gantt-chart.component.ts
Normal file
102
src/components/tl-gantt-chart/tl-gantt-chart.component.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent, IconComponent, TooltipDirective, SkeletonComponent } from '@sda/base-ui';
|
||||
import { TlGanttRowComponent } from '../tl-gantt-row';
|
||||
import { TlTimelineToolbarComponent } from '../tl-timeline-toolbar';
|
||||
import { GanttService } from '../../services/gantt.service';
|
||||
import type { TlGanttTask, TlGanttDependency, TlDateTick } from '../../types/gantt.types';
|
||||
import type { TlGanttTaskClickEvent, TlGanttTaskResizeEvent, TlGanttTaskMoveEvent, TlGanttZoomChangeEvent, TlGanttScrollEvent } from '../../types/event.types';
|
||||
import { calculateDateRange, sortTasksHierarchically, calculateChartWidth } from '../../utils/gantt.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-gantt-chart',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ButtonComponent,
|
||||
IconComponent,
|
||||
TooltipDirective,
|
||||
SkeletonComponent,
|
||||
TlGanttRowComponent,
|
||||
TlTimelineToolbarComponent,
|
||||
],
|
||||
providers: [GanttService],
|
||||
templateUrl: './tl-gantt-chart.component.html',
|
||||
styleUrl: './tl-gantt-chart.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TlGanttChartComponent {
|
||||
constructor(protected readonly ganttService: GanttService) {}
|
||||
|
||||
readonly tasks = input.required<TlGanttTask[]>();
|
||||
readonly dependencies = input<TlGanttDependency[]>([]);
|
||||
readonly zoomLevel = input<'day' | 'week' | 'month' | 'quarter' | 'year'>('week');
|
||||
readonly rowHeight = input(40);
|
||||
readonly showDependencies = input(true);
|
||||
readonly showProgress = input(true);
|
||||
readonly showTodayLine = input(true);
|
||||
readonly loading = input(false);
|
||||
readonly emptyMessage = input('No tasks');
|
||||
|
||||
readonly taskClick = output<TlGanttTaskClickEvent>();
|
||||
readonly taskResize = output<TlGanttTaskResizeEvent>();
|
||||
readonly taskMove = output<TlGanttTaskMoveEvent>();
|
||||
readonly zoomChange = output<TlGanttZoomChangeEvent>();
|
||||
readonly scroll = output<TlGanttScrollEvent>();
|
||||
|
||||
protected readonly dateRange = computed(() => {
|
||||
if (this.tasks().length === 0) {
|
||||
return { start: new Date(), end: new Date() };
|
||||
}
|
||||
return calculateDateRange(this.tasks());
|
||||
});
|
||||
|
||||
protected readonly dateAxis = computed(() => {
|
||||
const range = this.dateRange();
|
||||
return this.ganttService.calculateDateAxis(range.start, range.end);
|
||||
});
|
||||
|
||||
protected readonly sortedTasks = computed(() => {
|
||||
return sortTasksHierarchically(this.tasks());
|
||||
});
|
||||
|
||||
protected readonly taskPositions = computed(() => {
|
||||
const range = this.dateRange();
|
||||
return this.sortedTasks().map(task =>
|
||||
this.ganttService.calculateTaskPosition(task, range.start)
|
||||
);
|
||||
});
|
||||
|
||||
protected readonly todayPosition = computed(() => {
|
||||
if (!this.showTodayLine()) return null;
|
||||
const range = this.dateRange();
|
||||
return this.ganttService.calculateTodayPosition(range.start);
|
||||
});
|
||||
|
||||
protected readonly totalWidth = computed(() => {
|
||||
const range = this.dateRange();
|
||||
return calculateChartWidth(range.start, range.end, this.ganttService.pixelsPerDay());
|
||||
});
|
||||
|
||||
protected onZoomIn(): void {
|
||||
this.ganttService.zoomIn();
|
||||
this.zoomChange.emit({ level: this.ganttService.zoomLevel() });
|
||||
}
|
||||
|
||||
protected onZoomOut(): void {
|
||||
this.ganttService.zoomOut();
|
||||
this.zoomChange.emit({ level: this.ganttService.zoomLevel() });
|
||||
}
|
||||
|
||||
protected onTaskClick(event: TlGanttTaskClickEvent): void {
|
||||
this.taskClick.emit(event);
|
||||
}
|
||||
|
||||
protected onTaskResize(event: TlGanttTaskResizeEvent): void {
|
||||
this.taskResize.emit(event);
|
||||
}
|
||||
|
||||
protected getLoadingItems(): number[] {
|
||||
return Array.from({ length: 5 }, (_, i) => i);
|
||||
}
|
||||
}
|
||||
1
src/components/tl-gantt-row/index.ts
Normal file
1
src/components/tl-gantt-row/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-gantt-row.component';
|
||||
20
src/components/tl-gantt-row/tl-gantt-row.component.html
Normal file
20
src/components/tl-gantt-row/tl-gantt-row.component.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<div
|
||||
class="tl-gantt-row"
|
||||
[class]="statusClass()"
|
||||
[style.height.px]="rowHeight()"
|
||||
>
|
||||
<div
|
||||
class="tl-gantt-row__bar"
|
||||
[ngStyle]="barStyle()"
|
||||
[uiTooltip]="tooltipContent()"
|
||||
(click)="onTaskClick()"
|
||||
>
|
||||
@if (showProgress() && task().progress !== undefined) {
|
||||
<div
|
||||
class="tl-gantt-row__progress"
|
||||
[style.width]="progressWidth()"
|
||||
></div>
|
||||
}
|
||||
<span class="tl-gantt-row__label">{{ task().name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
79
src/components/tl-gantt-row/tl-gantt-row.component.scss
Normal file
79
src/components/tl-gantt-row/tl-gantt-row.component.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.tl-gantt-row {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
|
||||
&:nth-child(even) {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
&__bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: var(--tl-gantt-bar-height);
|
||||
border-radius: var(--radius-base, 4px);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: box-shadow var(--transition-fast, 150ms ease),
|
||||
transform var(--transition-fast, 150ms ease);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-50%) scaleY(1.08);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-right: 2px solid rgba(255, 255, 255, 0.4);
|
||||
transition: width var(--transition-normal, 200ms ease);
|
||||
}
|
||||
|
||||
&__label {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0 var(--spacing-sm, 0.75rem);
|
||||
line-height: var(--tl-gantt-bar-height);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-inverse, #ffffff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// Status modifiers
|
||||
&--completed &__bar {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
&--on-hold &__bar {
|
||||
opacity: 0.5;
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 4px,
|
||||
rgba(255, 255, 255, 0.1) 4px,
|
||||
rgba(255, 255, 255, 0.1) 8px
|
||||
);
|
||||
}
|
||||
|
||||
&--delayed &__bar {
|
||||
animation: tl-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tl-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
72
src/components/tl-gantt-row/tl-gantt-row.component.ts
Normal file
72
src/components/tl-gantt-row/tl-gantt-row.component.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||
import { NgStyle } from '@angular/common';
|
||||
import { TooltipDirective, IconComponent } from '@sda/base-ui';
|
||||
import type { TlGanttTask, TlGanttTaskPosition } from '../../types/gantt.types';
|
||||
import type { TlGanttTaskClickEvent, TlGanttTaskResizeEvent } from '../../types/event.types';
|
||||
import { formatDate, diffInDays } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-gantt-row',
|
||||
standalone: true,
|
||||
imports: [NgStyle, TooltipDirective, IconComponent],
|
||||
templateUrl: './tl-gantt-row.component.html',
|
||||
styleUrl: './tl-gantt-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.tl-gantt-row--clickable]': 'true',
|
||||
},
|
||||
})
|
||||
export class TlGanttRowComponent {
|
||||
readonly task = input.required<TlGanttTask>();
|
||||
readonly position = input.required<TlGanttTaskPosition>();
|
||||
readonly showProgress = input(true);
|
||||
readonly rowHeight = input(40);
|
||||
|
||||
readonly taskClick = output<TlGanttTaskClickEvent>();
|
||||
readonly taskResize = output<TlGanttTaskResizeEvent>();
|
||||
|
||||
protected readonly statusClass = computed(() => {
|
||||
const status = this.task().status;
|
||||
return status ? `tl-gantt-row--${status}` : '';
|
||||
});
|
||||
|
||||
protected readonly progressWidth = computed(() => {
|
||||
const progress = this.task().progress ?? 0;
|
||||
return `${Math.min(100, Math.max(0, progress))}%`;
|
||||
});
|
||||
|
||||
protected readonly barColor = computed(() => {
|
||||
const task = this.task();
|
||||
if (task.color) return task.color;
|
||||
if (task.status === 'completed') return 'var(--tl-status-completed)';
|
||||
if (task.status === 'delayed') return 'var(--tl-status-error)';
|
||||
if (task.status === 'on-hold') return 'var(--tl-status-warning)';
|
||||
if (task.status === 'in-progress') return 'var(--tl-status-active)';
|
||||
return 'var(--tl-status-pending)';
|
||||
});
|
||||
|
||||
protected readonly barStyle = computed(() => {
|
||||
const pos = this.position();
|
||||
return {
|
||||
left: `${pos.left}px`,
|
||||
width: `${pos.width}px`,
|
||||
'background-color': this.barColor(),
|
||||
};
|
||||
});
|
||||
|
||||
protected readonly tooltipContent = computed(() => {
|
||||
const task = this.task();
|
||||
const duration = diffInDays(task.startDate, task.endDate);
|
||||
return `
|
||||
${task.name}
|
||||
${formatDate(task.startDate)} - ${formatDate(task.endDate)}
|
||||
Duration: ${duration} days
|
||||
${task.progress !== undefined ? `Progress: ${task.progress}%` : ''}
|
||||
${task.assignee ? `Assignee: ${task.assignee}` : ''}
|
||||
`.trim();
|
||||
});
|
||||
|
||||
protected onTaskClick(): void {
|
||||
this.taskClick.emit({ task: this.task() });
|
||||
}
|
||||
}
|
||||
1
src/components/tl-horizontal-timeline/index.ts
Normal file
1
src/components/tl-horizontal-timeline/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-horizontal-timeline.component';
|
||||
@@ -0,0 +1,75 @@
|
||||
<div class="tl-horizontal-timeline">
|
||||
@if (showNavArrows && canScrollLeft) {
|
||||
<ui-button
|
||||
class="tl-horizontal-timeline__nav tl-horizontal-timeline__nav--left"
|
||||
|
||||
|
||||
(click)="scrollLeft()"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ui-icon name="chevron-left" />
|
||||
</ui-button>
|
||||
}
|
||||
|
||||
<div
|
||||
class="tl-horizontal-timeline__container"
|
||||
#scrollContainer
|
||||
(scroll)="updateScrollState()"
|
||||
>
|
||||
@if (loading) {
|
||||
<div class="tl-horizontal-timeline__loading">
|
||||
@for (i of getLoadingItems(); track i) {
|
||||
<div class="tl-horizontal-timeline__skeleton">
|
||||
<ui-skeleton width="32px" height="32px" variant="circle" />
|
||||
<ui-skeleton width="120px" height="60px" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (items.length === 0) {
|
||||
<div class="tl-horizontal-timeline__empty">
|
||||
<ui-icon name="calendar" />
|
||||
<p>{{ emptyMessage }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tl-horizontal-timeline__track">
|
||||
@for (item of sortedItems; track item.id; let idx = $index) {
|
||||
<div class="tl-horizontal-timeline__item" (click)="onItemClick(item, idx)">
|
||||
<div
|
||||
class="tl-horizontal-timeline__marker"
|
||||
[style.background-color]="getMarkerColor(item)"
|
||||
[uiTooltip]="item.title"
|
||||
>
|
||||
@if (item.icon) {
|
||||
<ui-icon [name]="item.icon" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tl-horizontal-timeline__content">
|
||||
<time class="tl-horizontal-timeline__time">{{ formatTime(item) }}</time>
|
||||
<h4 class="tl-horizontal-timeline__title">{{ item.title }}</h4>
|
||||
@if (item.description) {
|
||||
<p class="tl-horizontal-timeline__description">{{ item.description }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!$last) {
|
||||
<div class="tl-horizontal-timeline__connector"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showNavArrows && canScrollRight) {
|
||||
<ui-button
|
||||
class="tl-horizontal-timeline__nav tl-horizontal-timeline__nav--right"
|
||||
|
||||
|
||||
(click)="scrollRight()"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ui-icon name="chevron-right" />
|
||||
</ui-button>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.tl-horizontal-timeline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
|
||||
&__nav {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
|
||||
|
||||
&--left {
|
||||
left: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: var(--spacing-md, 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
@include tl-scrollable;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
padding: var(--spacing-xl, 2rem) var(--spacing-lg, 1.5rem);
|
||||
}
|
||||
|
||||
&__loading,
|
||||
&__track {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xl, 2.5rem);
|
||||
align-items: flex-start;
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg, 1.5rem);
|
||||
width: 100%;
|
||||
padding: var(--spacing-2xl, 3rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
line-height: var(--line-height-relaxed, 1.625);
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md, 1rem);
|
||||
min-width: 160px;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-normal, 200ms ease);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
|
||||
.tl-horizontal-timeline__content {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__marker {
|
||||
@include tl-marker(md);
|
||||
}
|
||||
|
||||
&__connector {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 100%;
|
||||
width: var(--spacing-2xl, 2.5rem);
|
||||
height: var(--tl-line-width);
|
||||
background: var(--tl-line-color);
|
||||
}
|
||||
|
||||
&__content {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
padding: var(--spacing-md, 1rem) var(--spacing-lg, 1.5rem);
|
||||
text-align: center;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
transition: all var(--transition-normal, 200ms ease);
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-bottom: var(--spacing-xs, 0.5rem);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 var(--spacing-xs, 0.5rem);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--color-text-primary, #111827);
|
||||
line-height: var(--line-height-snug, 1.375);
|
||||
letter-spacing: var(--letter-spacing-tight, -0.025em);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
line-height: var(--line-height-normal, 1.5);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ButtonComponent, IconComponent, SkeletonComponent, TooltipDirective } from '@sda/base-ui';
|
||||
import type { TlTimelineItem } from '../../types/timeline.types';
|
||||
import type { TlMarkerSize, TlConnectorStyle, TlSortDirection } from '../../types/config.types';
|
||||
import type { TlTimelineItemClickEvent } from '../../types/event.types';
|
||||
import { formatSmartTime } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-horizontal-timeline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ButtonComponent, IconComponent, SkeletonComponent, TooltipDirective],
|
||||
templateUrl: './tl-horizontal-timeline.component.html',
|
||||
styleUrl: './tl-horizontal-timeline.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TlHorizontalTimelineComponent implements AfterViewInit {
|
||||
@ViewChild('scrollContainer') scrollContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
@Input({ required: true }) items!: TlTimelineItem[];
|
||||
@Input() markerSize: TlMarkerSize = 'md';
|
||||
@Input() connectorStyle: TlConnectorStyle = 'solid';
|
||||
@Input() sortDirection: TlSortDirection = 'desc';
|
||||
@Input() scrollable = true;
|
||||
@Input() showNavArrows = true;
|
||||
@Input() loading = false;
|
||||
@Input() emptyMessage = 'No timeline items';
|
||||
|
||||
@Output() itemClick = new EventEmitter<TlTimelineItemClickEvent>();
|
||||
|
||||
protected canScrollLeft = false;
|
||||
protected canScrollRight = false;
|
||||
|
||||
protected get sortedItems(): TlTimelineItem[] {
|
||||
const items = [...this.items];
|
||||
const direction = this.sortDirection;
|
||||
|
||||
items.sort((a, b) => {
|
||||
const aTime = new Date(a.date).getTime();
|
||||
const bTime = new Date(b.date).getTime();
|
||||
return direction === 'asc' ? aTime - bTime : bTime - aTime;
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.updateScrollState();
|
||||
}
|
||||
|
||||
protected formatTime(item: TlTimelineItem): string {
|
||||
return formatSmartTime(item.date);
|
||||
}
|
||||
|
||||
protected getMarkerColor(item: TlTimelineItem): string {
|
||||
if (item.iconColor) return item.iconColor;
|
||||
if (item.status) return `var(--tl-status-${item.status})`;
|
||||
return 'var(--tl-marker-bg)';
|
||||
}
|
||||
|
||||
protected onItemClick(item: TlTimelineItem, index: number): void {
|
||||
this.itemClick.emit({ item, index });
|
||||
}
|
||||
|
||||
protected scrollLeft(): void {
|
||||
const container = this.scrollContainer?.nativeElement;
|
||||
if (container) {
|
||||
container.scrollBy({ left: -200, behavior: 'smooth' });
|
||||
setTimeout(() => this.updateScrollState(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
protected scrollRight(): void {
|
||||
const container = this.scrollContainer?.nativeElement;
|
||||
if (container) {
|
||||
container.scrollBy({ left: 200, behavior: 'smooth' });
|
||||
setTimeout(() => this.updateScrollState(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
protected updateScrollState(): void {
|
||||
const container = this.scrollContainer?.nativeElement;
|
||||
if (container) {
|
||||
this.canScrollLeft = container.scrollLeft > 0;
|
||||
this.canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected getLoadingItems(): number[] {
|
||||
return Array.from({ length: 5 }, (_, i) => i);
|
||||
}
|
||||
}
|
||||
1
src/components/tl-timeline-item/index.ts
Normal file
1
src/components/tl-timeline-item/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-timeline-item.component';
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="tl-timeline-item" [class]="statusClass">
|
||||
<div class="tl-timeline-item__marker" [style.background-color]="markerColor">
|
||||
@if (item.icon) {
|
||||
<ui-icon [name]="item.icon" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tl-timeline-item__content">
|
||||
<ui-card class="tl-timeline-item__card">
|
||||
<div class="tl-timeline-item__header">
|
||||
<h4 class="tl-timeline-item__title">{{ item.title }}</h4>
|
||||
<time class="tl-timeline-item__time">{{ formattedTime }}</time>
|
||||
</div>
|
||||
|
||||
@if (item.description) {
|
||||
<p class="tl-timeline-item__description">{{ item.description }}</p>
|
||||
}
|
||||
|
||||
@if (item.tags?.length) {
|
||||
<div class="tl-timeline-item__tags">
|
||||
@for (tag of item.tags; track tag) {
|
||||
<ui-badge>{{ tag }}</ui-badge>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item.category) {
|
||||
<div class="tl-timeline-item__category">
|
||||
<ui-badge>{{ item.category }}</ui-badge>
|
||||
</div>
|
||||
}
|
||||
</ui-card>
|
||||
</div>
|
||||
</div>
|
||||
124
src/components/tl-timeline-item/tl-timeline-item.component.scss
Normal file
124
src/components/tl-timeline-item/tl-timeline-item.component.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.tl-timeline-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--spacing-lg, 1.5rem);
|
||||
padding: var(--spacing-md, 1rem) 0;
|
||||
|
||||
&::after {
|
||||
@include tl-connector(solid);
|
||||
left: calc(var(--tl-marker-size-md) / 2 - var(--tl-line-width) / 2);
|
||||
top: calc(var(--tl-marker-size-md) + var(--spacing-md, 1rem));
|
||||
bottom: calc(-1 * var(--spacing-md, 1rem));
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--animate {
|
||||
animation: tl-slide-in 300ms ease-out;
|
||||
}
|
||||
|
||||
&__marker {
|
||||
@include tl-marker(md);
|
||||
}
|
||||
|
||||
&__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__card {
|
||||
background: var(--color-bg-primary, #ffffff);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
padding: var(--spacing-lg, 1.5rem);
|
||||
transition: all var(--transition-normal, 200ms ease);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-sm, 0.75rem);
|
||||
gap: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--color-text-primary, #111827);
|
||||
line-height: var(--line-height-snug, 1.375);
|
||||
letter-spacing: var(--letter-spacing-tight, -0.025em);
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
white-space: nowrap;
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: var(--spacing-xs, 0.5rem) 0 0;
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
line-height: var(--line-height-relaxed, 1.625);
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs, 0.5rem);
|
||||
margin-top: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
&__category {
|
||||
margin-top: var(--spacing-md, 1rem);
|
||||
}
|
||||
|
||||
// Status colors
|
||||
&--completed .tl-timeline-item__marker {
|
||||
background: var(--tl-status-completed);
|
||||
}
|
||||
|
||||
&--active .tl-timeline-item__marker {
|
||||
background: var(--tl-status-active);
|
||||
}
|
||||
|
||||
&--pending .tl-timeline-item__marker {
|
||||
background: var(--tl-status-pending);
|
||||
}
|
||||
|
||||
&--error .tl-timeline-item__marker {
|
||||
background: var(--tl-status-error);
|
||||
}
|
||||
|
||||
&--warning .tl-timeline-item__marker {
|
||||
background: var(--tl-status-warning);
|
||||
}
|
||||
|
||||
// Alternating layout
|
||||
.tl-timeline-item--alternating &:nth-child(even) {
|
||||
grid-template-columns: 1fr auto;
|
||||
direction: rtl;
|
||||
|
||||
.tl-timeline-item__content {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tl-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { IconComponent, CardComponent, BadgeComponent, TooltipDirective } from '@sda/base-ui';
|
||||
import type { TlTimelineItem } from '../../types/timeline.types';
|
||||
import type { TlTimelineLayout, TlMarkerSize, TlConnectorStyle } from '../../types/config.types';
|
||||
import type { TlTimelineItemClickEvent, TlTimelineItemHoverEvent } from '../../types/event.types';
|
||||
import { formatSmartTime } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-timeline-item',
|
||||
standalone: true,
|
||||
imports: [IconComponent, CardComponent, BadgeComponent, TooltipDirective],
|
||||
templateUrl: './tl-timeline-item.component.html',
|
||||
styleUrl: './tl-timeline-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.tl-timeline-item--left]': 'layout === "left"',
|
||||
'[class.tl-timeline-item--right]': 'layout === "right"',
|
||||
'[class.tl-timeline-item--alternating]': 'layout === "alternating"',
|
||||
'[class.tl-timeline-item--animate]': 'animate',
|
||||
},
|
||||
})
|
||||
export class TlTimelineItemComponent {
|
||||
@Input({ required: true }) item!: TlTimelineItem;
|
||||
@Input() layout: TlTimelineLayout = 'left';
|
||||
@Input() markerSize: TlMarkerSize = 'md';
|
||||
@Input() connectorStyle: TlConnectorStyle = 'solid';
|
||||
@Input() animate = false;
|
||||
@Input() position: 'left' | 'right' = 'left';
|
||||
|
||||
@Output() itemClick = new EventEmitter<TlTimelineItemClickEvent>();
|
||||
@Output() itemHover = new EventEmitter<TlTimelineItemHoverEvent>();
|
||||
|
||||
protected get formattedTime(): string {
|
||||
return formatSmartTime(this.item.date);
|
||||
}
|
||||
|
||||
protected get markerColor(): string {
|
||||
if (this.item.iconColor) return this.item.iconColor;
|
||||
if (this.item.status) return `var(--tl-status-${this.item.status})`;
|
||||
return 'var(--tl-marker-bg)';
|
||||
}
|
||||
|
||||
protected get statusClass(): string {
|
||||
return this.item.status ? `tl-timeline-item--${this.item.status}` : '';
|
||||
}
|
||||
|
||||
protected onClick(index: number): void {
|
||||
this.itemClick.emit({ item: this.item, index });
|
||||
}
|
||||
|
||||
protected onHover(index: number): void {
|
||||
this.itemHover.emit({ item: this.item, index });
|
||||
}
|
||||
}
|
||||
1
src/components/tl-timeline-toolbar/index.ts
Normal file
1
src/components/tl-timeline-toolbar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-timeline-toolbar.component';
|
||||
@@ -0,0 +1,56 @@
|
||||
<div class="tl-timeline-toolbar">
|
||||
@if (searchable()) {
|
||||
<input
|
||||
class="tl-timeline-toolbar__search"
|
||||
type="text"
|
||||
[placeholder]="searchPlaceholder()"
|
||||
#searchInput
|
||||
(input)="onSearchChange(searchInput.value)"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="tl-timeline-toolbar__actions">
|
||||
@if (showFilter()) {
|
||||
<ui-button
|
||||
|
||||
|
||||
(click)="onClearFilters()"
|
||||
>
|
||||
<ui-icon name="filter" />
|
||||
@if (filterCount() > 0) {
|
||||
<ui-badge>{{ filterCount() }}</ui-badge>
|
||||
}
|
||||
</ui-button>
|
||||
}
|
||||
|
||||
@if (showZoom()) {
|
||||
<ui-button
|
||||
|
||||
|
||||
(click)="onZoomOut()"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ui-icon name="zoom-out" />
|
||||
</ui-button>
|
||||
<ui-button
|
||||
|
||||
|
||||
(click)="onZoomIn()"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ui-icon name="zoom-in" />
|
||||
</ui-button>
|
||||
}
|
||||
|
||||
@if (showSort()) {
|
||||
<ui-button
|
||||
|
||||
|
||||
(click)="onSortToggle()"
|
||||
aria-label="Toggle sort"
|
||||
>
|
||||
<ui-icon name="sort" />
|
||||
</ui-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
.tl-timeline-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs, 0.5rem);
|
||||
padding: var(--spacing-xs, 0.5rem) var(--spacing-md, 1rem);
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
margin-bottom: var(--spacing-sm, 0.75rem);
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs, 0.5rem);
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { ButtonComponent, IconComponent, BadgeComponent, InputComponent } from '@sda/base-ui';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-timeline-toolbar',
|
||||
standalone: true,
|
||||
imports: [ButtonComponent, IconComponent, BadgeComponent, InputComponent],
|
||||
templateUrl: './tl-timeline-toolbar.component.html',
|
||||
styleUrl: './tl-timeline-toolbar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TlTimelineToolbarComponent {
|
||||
readonly searchable = input(false);
|
||||
readonly searchPlaceholder = input('Search...');
|
||||
readonly showFilter = input(false);
|
||||
readonly showZoom = input(false);
|
||||
readonly showSort = input(false);
|
||||
readonly filterCount = input(0);
|
||||
|
||||
readonly searchChange = output<string>();
|
||||
readonly clearFilters = output<void>();
|
||||
readonly zoomIn = output<void>();
|
||||
readonly zoomOut = output<void>();
|
||||
readonly sortToggle = output<void>();
|
||||
|
||||
protected onSearchChange(value: string): void {
|
||||
this.searchChange.emit(value);
|
||||
}
|
||||
|
||||
protected onClearFilters(): void {
|
||||
this.clearFilters.emit();
|
||||
}
|
||||
|
||||
protected onZoomIn(): void {
|
||||
this.zoomIn.emit();
|
||||
}
|
||||
|
||||
protected onZoomOut(): void {
|
||||
this.zoomOut.emit();
|
||||
}
|
||||
|
||||
protected onSortToggle(): void {
|
||||
this.sortToggle.emit();
|
||||
}
|
||||
}
|
||||
1
src/components/tl-timeline/index.ts
Normal file
1
src/components/tl-timeline/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './tl-timeline.component';
|
||||
54
src/components/tl-timeline/tl-timeline.component.html
Normal file
54
src/components/tl-timeline/tl-timeline.component.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<div class="tl-timeline">
|
||||
@if (loading) {
|
||||
<div class="tl-timeline__loading">
|
||||
@for (i of getLoadingItems(); track i) {
|
||||
<div class="tl-timeline__skeleton">
|
||||
<ui-skeleton width="32px" height="32px" variant="circle" />
|
||||
<ui-skeleton width="100%" height="80px" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (items.length === 0) {
|
||||
<div class="tl-timeline__empty">
|
||||
<ui-icon name="calendar" />
|
||||
<p>{{ emptyMessage }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (groupByDate) {
|
||||
@for (group of groupedItems; track group.label) {
|
||||
<div class="tl-timeline__group">
|
||||
<h3 class="tl-timeline__group-label">{{ group.label }}</h3>
|
||||
<div class="tl-timeline__items">
|
||||
@for (item of group.items; track item.id) {
|
||||
<tl-timeline-item
|
||||
[item]="item"
|
||||
[layout]="layout"
|
||||
[markerSize]="markerSize"
|
||||
[connectorStyle]="connectorStyle"
|
||||
[animate]="animate"
|
||||
[position]="getItemPosition($index)"
|
||||
(itemClick)="onItemClick($event)"
|
||||
(itemHover)="onItemHover($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="tl-timeline__items">
|
||||
@for (item of sortedItems; track item.id) {
|
||||
<tl-timeline-item
|
||||
[item]="item"
|
||||
[layout]="layout"
|
||||
[markerSize]="markerSize"
|
||||
[connectorStyle]="connectorStyle"
|
||||
[animate]="animate"
|
||||
[position]="getItemPosition($index)"
|
||||
(itemClick)="onItemClick($event)"
|
||||
(itemHover)="onItemHover($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
57
src/components/tl-timeline/tl-timeline.component.scss
Normal file
57
src/components/tl-timeline/tl-timeline.component.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.tl-timeline {
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl, 2rem);
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--spacing-lg, 1.5rem);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg, 1.5rem);
|
||||
padding: var(--spacing-2xl, 3rem);
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 1rem);
|
||||
line-height: var(--line-height-relaxed, 1.625);
|
||||
}
|
||||
}
|
||||
|
||||
&__group {
|
||||
margin-bottom: var(--spacing-2xl, 2.5rem);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__group-label {
|
||||
margin: 0 0 var(--spacing-xl, 2rem);
|
||||
font-size: var(--font-size-lg, 1.125rem);
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--color-text-primary, #111827);
|
||||
padding-bottom: var(--spacing-md, 1rem);
|
||||
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
line-height: var(--line-height-tight, 1.25);
|
||||
letter-spacing: var(--letter-spacing-tight, -0.025em);
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
73
src/components/tl-timeline/tl-timeline.component.ts
Normal file
73
src/components/tl-timeline/tl-timeline.component.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SkeletonComponent, IconComponent } from '@sda/base-ui';
|
||||
import { TlTimelineItemComponent } from '../tl-timeline-item';
|
||||
import type { TlTimelineItem } from '../../types/timeline.types';
|
||||
import type { TlTimelineLayout, TlMarkerSize, TlConnectorStyle, TlSortDirection } from '../../types/config.types';
|
||||
import type { TlTimelineItemClickEvent, TlTimelineItemHoverEvent } from '../../types/event.types';
|
||||
import { groupByDate } from '../../utils/date.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'tl-timeline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SkeletonComponent, IconComponent, TlTimelineItemComponent],
|
||||
templateUrl: './tl-timeline.component.html',
|
||||
styleUrl: './tl-timeline.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.tl-timeline--left]': 'layout === "left"',
|
||||
'[class.tl-timeline--right]': 'layout === "right"',
|
||||
'[class.tl-timeline--alternating]': 'layout === "alternating"',
|
||||
},
|
||||
})
|
||||
export class TlTimelineComponent {
|
||||
@Input({ required: true }) items!: TlTimelineItem[];
|
||||
@Input() layout: TlTimelineLayout = 'left';
|
||||
@Input() markerSize: TlMarkerSize = 'md';
|
||||
@Input() connectorStyle: TlConnectorStyle = 'solid';
|
||||
@Input() sortDirection: TlSortDirection = 'desc';
|
||||
@Input() groupByDate = false;
|
||||
@Input() animate = false;
|
||||
@Input() loading = false;
|
||||
@Input() emptyMessage = 'No timeline items';
|
||||
|
||||
@Output() itemClick = new EventEmitter<TlTimelineItemClickEvent>();
|
||||
@Output() itemHover = new EventEmitter<TlTimelineItemHoverEvent>();
|
||||
|
||||
protected get sortedItems(): TlTimelineItem[] {
|
||||
const items = [...this.items];
|
||||
const direction = this.sortDirection;
|
||||
|
||||
items.sort((a, b) => {
|
||||
const aTime = new Date(a.date).getTime();
|
||||
const bTime = new Date(b.date).getTime();
|
||||
return direction === 'asc' ? aTime - bTime : bTime - aTime;
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
protected get groupedItems() {
|
||||
if (!this.groupByDate) return null;
|
||||
return groupByDate(this.sortedItems);
|
||||
}
|
||||
|
||||
protected getLoadingItems(): number[] {
|
||||
return Array.from({ length: 5 }, (_, i) => i);
|
||||
}
|
||||
|
||||
protected onItemClick(event: TlTimelineItemClickEvent): void {
|
||||
this.itemClick.emit(event);
|
||||
}
|
||||
|
||||
protected onItemHover(event: TlTimelineItemHoverEvent): void {
|
||||
this.itemHover.emit(event);
|
||||
}
|
||||
|
||||
protected getItemPosition(index: number): 'left' | 'right' {
|
||||
if (this.layout === 'alternating') {
|
||||
return index % 2 === 0 ? 'left' : 'right';
|
||||
}
|
||||
return 'left';
|
||||
}
|
||||
}
|
||||
65
src/index.ts
Normal file
65
src/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Timeline Elements UI
|
||||
* Main library entry point
|
||||
*
|
||||
* Angular components for timeline, activity feed, audit trail, changelog,
|
||||
* and Gantt chart displays powered by @sda/base-ui
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Component } from '@angular/core';
|
||||
* import { TlTimelineComponent, type TlTimelineItem } from '@sda/timeline-elements-ui';
|
||||
*
|
||||
* @Component({
|
||||
* standalone: true,
|
||||
* imports: [TlTimelineComponent],
|
||||
* template: `
|
||||
* <tl-timeline
|
||||
* [items]="events"
|
||||
* layout="left"
|
||||
* [groupByDate]="true"
|
||||
* (itemClick)="onItemClick($event)"
|
||||
* />
|
||||
* `
|
||||
* })
|
||||
* export class AppComponent {
|
||||
* events: TlTimelineItem[] = [
|
||||
* {
|
||||
* id: '1',
|
||||
* title: 'Tenancy started',
|
||||
* description: 'John Doe moved in',
|
||||
* date: new Date('2024-01-01'),
|
||||
* icon: 'home',
|
||||
* status: 'completed'
|
||||
* },
|
||||
* {
|
||||
* id: '2',
|
||||
* title: 'Inspection scheduled',
|
||||
* description: 'Quarterly property inspection',
|
||||
* date: new Date('2024-02-15'),
|
||||
* icon: 'clipboard',
|
||||
* status: 'pending'
|
||||
* }
|
||||
* ];
|
||||
*
|
||||
* onItemClick(event: any) {
|
||||
* console.log('Clicked:', event.item);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
|
||||
// Providers
|
||||
export * from './providers';
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Components
|
||||
export * from './components';
|
||||
1
src/providers/index.ts
Normal file
1
src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './timeline-config.provider';
|
||||
56
src/providers/timeline-config.provider.ts
Normal file
56
src/providers/timeline-config.provider.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders } from '@angular/core';
|
||||
import type { TlConfig } from '../types/config.types';
|
||||
|
||||
export const DEFAULT_TIMELINE_CONFIG: TlConfig = {
|
||||
locale: 'en-US',
|
||||
dateFormat: { year: 'numeric', month: 'short', day: 'numeric' },
|
||||
timeFormat: { hour: '2-digit', minute: '2-digit' },
|
||||
relativeTime: true,
|
||||
animate: true,
|
||||
timeline: {
|
||||
layout: 'left',
|
||||
markerSize: 'md',
|
||||
connectorStyle: 'solid',
|
||||
sortDirection: 'desc',
|
||||
groupByDate: false,
|
||||
},
|
||||
activity: {
|
||||
pageSize: 20,
|
||||
showUnreadIndicator: true,
|
||||
groupByDate: true,
|
||||
},
|
||||
audit: {
|
||||
pageSize: 25,
|
||||
searchable: true,
|
||||
filterable: true,
|
||||
expandable: true,
|
||||
showSeverityIcons: true,
|
||||
},
|
||||
gantt: {
|
||||
zoomLevel: 'week',
|
||||
rowHeight: 40,
|
||||
showDependencies: true,
|
||||
showProgress: true,
|
||||
showTodayLine: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const TIMELINE_CONFIG = new InjectionToken<TlConfig>('TIMELINE_CONFIG', {
|
||||
providedIn: 'root',
|
||||
factory: () => DEFAULT_TIMELINE_CONFIG,
|
||||
});
|
||||
|
||||
export function provideTimelineConfig(config: Partial<TlConfig> = {}): EnvironmentProviders {
|
||||
const merged: TlConfig = {
|
||||
...DEFAULT_TIMELINE_CONFIG,
|
||||
...config,
|
||||
timeline: { ...DEFAULT_TIMELINE_CONFIG.timeline, ...config.timeline },
|
||||
activity: { ...DEFAULT_TIMELINE_CONFIG.activity, ...config.activity },
|
||||
audit: { ...DEFAULT_TIMELINE_CONFIG.audit, ...config.audit },
|
||||
gantt: { ...DEFAULT_TIMELINE_CONFIG.gantt, ...config.gantt },
|
||||
};
|
||||
|
||||
return makeEnvironmentProviders([
|
||||
{ provide: TIMELINE_CONFIG, useValue: merged },
|
||||
]);
|
||||
}
|
||||
68
src/services/gantt.service.ts
Normal file
68
src/services/gantt.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import type { TlGanttZoomLevel } from '../types/config.types';
|
||||
import type { TlGanttTask, TlGanttTaskPosition, TlDateTick } from '../types/gantt.types';
|
||||
import { getPixelsPerDay, generateTicks, calculateTaskPosition, calculateTodayOffset } from '../utils/gantt.utils';
|
||||
import { toDate } from '../utils/date.utils';
|
||||
|
||||
@Injectable()
|
||||
export class GanttService {
|
||||
private readonly _zoomLevel = signal<TlGanttZoomLevel>('week');
|
||||
private readonly _scrollOffset = signal(0);
|
||||
private readonly _selectedTaskId = signal<string | null>(null);
|
||||
private readonly _collapsedGroups = signal<Set<string>>(new Set());
|
||||
|
||||
readonly zoomLevel = this._zoomLevel.asReadonly();
|
||||
readonly scrollOffset = this._scrollOffset.asReadonly();
|
||||
readonly selectedTaskId = this._selectedTaskId.asReadonly();
|
||||
readonly collapsedGroups = this._collapsedGroups.asReadonly();
|
||||
|
||||
readonly pixelsPerDay = computed(() => getPixelsPerDay(this._zoomLevel()));
|
||||
|
||||
private readonly zoomLevels: TlGanttZoomLevel[] = ['day', 'week', 'month', 'quarter', 'year'];
|
||||
|
||||
zoomIn(): void {
|
||||
const current = this.zoomLevels.indexOf(this._zoomLevel());
|
||||
if (current > 0) {
|
||||
this._zoomLevel.set(this.zoomLevels[current - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
zoomOut(): void {
|
||||
const current = this.zoomLevels.indexOf(this._zoomLevel());
|
||||
if (current < this.zoomLevels.length - 1) {
|
||||
this._zoomLevel.set(this.zoomLevels[current + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
setZoom(level: TlGanttZoomLevel): void {
|
||||
this._zoomLevel.set(level);
|
||||
}
|
||||
|
||||
selectTask(taskId: string | null): void {
|
||||
this._selectedTaskId.set(taskId);
|
||||
}
|
||||
|
||||
toggleGroupCollapse(groupId: string): void {
|
||||
this._collapsedGroups.update(groups => {
|
||||
const newGroups = new Set(groups);
|
||||
if (newGroups.has(groupId)) {
|
||||
newGroups.delete(groupId);
|
||||
} else {
|
||||
newGroups.add(groupId);
|
||||
}
|
||||
return newGroups;
|
||||
});
|
||||
}
|
||||
|
||||
calculateDateAxis(start: Date, end: Date): TlDateTick[] {
|
||||
return generateTicks(start, end, this._zoomLevel());
|
||||
}
|
||||
|
||||
calculateTaskPosition(task: TlGanttTask, chartStart: Date): TlGanttTaskPosition {
|
||||
return calculateTaskPosition(task, chartStart, this.pixelsPerDay());
|
||||
}
|
||||
|
||||
calculateTodayPosition(chartStart: Date): number {
|
||||
return calculateTodayOffset(chartStart, this.pixelsPerDay());
|
||||
}
|
||||
}
|
||||
2
src/services/index.ts
Normal file
2
src/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './timeline.service';
|
||||
export * from './gantt.service';
|
||||
49
src/services/timeline.service.ts
Normal file
49
src/services/timeline.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import type { TlAuditSeverity } from '../types/audit.types';
|
||||
|
||||
@Injectable()
|
||||
export class TimelineService {
|
||||
private readonly _searchTerm = signal('');
|
||||
private readonly _categoryFilter = signal<string[]>([]);
|
||||
private readonly _severityFilter = signal<TlAuditSeverity[]>([]);
|
||||
private readonly _dateRangeFilter = signal<{ start?: Date; end?: Date } | null>(null);
|
||||
|
||||
readonly searchTerm = this._searchTerm.asReadonly();
|
||||
readonly categoryFilter = this._categoryFilter.asReadonly();
|
||||
readonly severityFilter = this._severityFilter.asReadonly();
|
||||
readonly dateRangeFilter = this._dateRangeFilter.asReadonly();
|
||||
|
||||
readonly activeFilterCount = computed(() => {
|
||||
let count = 0;
|
||||
if (this._searchTerm().trim()) count++;
|
||||
if (this._categoryFilter().length) count++;
|
||||
if (this._severityFilter().length) count++;
|
||||
if (this._dateRangeFilter()) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
readonly hasFilters = computed(() => this.activeFilterCount() > 0);
|
||||
|
||||
setSearch(term: string): void {
|
||||
this._searchTerm.set(term);
|
||||
}
|
||||
|
||||
setCategoryFilter(categories: string[]): void {
|
||||
this._categoryFilter.set(categories);
|
||||
}
|
||||
|
||||
setSeverityFilter(severities: TlAuditSeverity[]): void {
|
||||
this._severityFilter.set(severities);
|
||||
}
|
||||
|
||||
setDateRange(range: { start?: Date; end?: Date } | null): void {
|
||||
this._dateRangeFilter.set(range);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this._searchTerm.set('');
|
||||
this._categoryFilter.set([]);
|
||||
this._severityFilter.set([]);
|
||||
this._dateRangeFilter.set(null);
|
||||
}
|
||||
}
|
||||
2
src/styles/_index.scss
Normal file
2
src/styles/_index.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@forward './tokens';
|
||||
@forward './mixins';
|
||||
88
src/styles/_mixins.scss
Normal file
88
src/styles/_mixins.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
// =============================================================================
|
||||
// Timeline Elements UI - Mixins
|
||||
// Timeline-specific mixins only. General utilities come from @sda/base-ui.
|
||||
// =============================================================================
|
||||
|
||||
// Timeline connector line
|
||||
@mixin tl-connector($style: solid) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: var(--tl-line-width);
|
||||
background: var(--tl-line-color);
|
||||
@if $style == dashed {
|
||||
background: none;
|
||||
border-left: var(--tl-line-width) dashed var(--tl-line-color);
|
||||
} @else if $style == dotted {
|
||||
background: none;
|
||||
border-left: var(--tl-line-width) dotted var(--tl-line-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline marker
|
||||
@mixin tl-marker($size: md) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--tl-marker-bg);
|
||||
color: var(--tl-marker-text);
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
|
||||
@if $size == sm {
|
||||
width: var(--tl-marker-size-sm);
|
||||
height: var(--tl-marker-size-sm);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
} @else if $size == md {
|
||||
width: var(--tl-marker-size-md);
|
||||
height: var(--tl-marker-size-md);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
} @else if $size == lg {
|
||||
width: var(--tl-marker-size-lg);
|
||||
height: var(--tl-marker-size-lg);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable container with styled scrollbar
|
||||
@mixin tl-scrollable {
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
border-radius: var(--radius-base, 4px);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-base, 4px);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status color helper
|
||||
@mixin tl-status-color($status) {
|
||||
@if $status == completed { color: var(--tl-status-completed); }
|
||||
@else if $status == active { color: var(--tl-status-active); }
|
||||
@else if $status == pending { color: var(--tl-status-pending); }
|
||||
@else if $status == error { color: var(--tl-status-error); }
|
||||
@else if $status == warning { color: var(--tl-status-warning); }
|
||||
}
|
||||
|
||||
// Severity color helper
|
||||
@mixin tl-severity-color($severity) {
|
||||
@if $severity == info { color: var(--tl-severity-info); }
|
||||
@else if $severity == warning { color: var(--tl-severity-warning); }
|
||||
@else if $severity == error { color: var(--tl-severity-error); }
|
||||
@else if $severity == critical { color: var(--tl-severity-critical); }
|
||||
@else if $severity == debug { color: var(--tl-severity-debug); }
|
||||
}
|
||||
57
src/styles/_tokens.scss
Normal file
57
src/styles/_tokens.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
// =============================================================================
|
||||
// Timeline Elements UI - Design Tokens
|
||||
// Only timeline-specific tokens. Everything else comes from @sda/base-ui.
|
||||
// =============================================================================
|
||||
|
||||
:root {
|
||||
// Timeline-specific: Connector line
|
||||
--tl-line-color: var(--color-border-primary, #e5e7eb);
|
||||
--tl-line-width: 2px;
|
||||
|
||||
// Timeline-specific: Markers
|
||||
--tl-marker-size-sm: 24px;
|
||||
--tl-marker-size-md: 32px;
|
||||
--tl-marker-size-lg: 40px;
|
||||
--tl-marker-bg: var(--color-primary-500, #3b82f6);
|
||||
--tl-marker-text: var(--color-text-inverse, #ffffff);
|
||||
|
||||
// Timeline-specific: Status colors
|
||||
--tl-status-completed: var(--color-status-success, #10b981);
|
||||
--tl-status-active: var(--color-primary-500, #3b82f6);
|
||||
--tl-status-pending: var(--color-text-muted, #9ca3af);
|
||||
--tl-status-error: var(--color-status-error, #ef4444);
|
||||
--tl-status-warning: var(--color-status-warning, #f59e0b);
|
||||
|
||||
// Timeline-specific: Severity colors
|
||||
--tl-severity-info: var(--color-status-info, #3b82f6);
|
||||
--tl-severity-warning: var(--color-status-warning, #f59e0b);
|
||||
--tl-severity-error: var(--color-status-error, #ef4444);
|
||||
--tl-severity-critical: #dc2626;
|
||||
--tl-severity-debug: #8b5cf6;
|
||||
|
||||
// Timeline-specific: Changelog
|
||||
--tl-change-added: var(--color-status-success, #10b981);
|
||||
--tl-change-changed: var(--color-primary-500, #3b82f6);
|
||||
--tl-change-fixed: #8b5cf6;
|
||||
--tl-change-removed: var(--color-status-error, #ef4444);
|
||||
--tl-change-deprecated: var(--color-status-warning, #f59e0b);
|
||||
--tl-change-security: #dc2626;
|
||||
|
||||
// Timeline-specific: Gantt
|
||||
--tl-gantt-bar-height: 24px;
|
||||
--tl-gantt-bar-radius: var(--radius-base, 4px);
|
||||
--tl-gantt-row-height: 40px;
|
||||
--tl-gantt-row-border: var(--color-border-primary, #e5e7eb);
|
||||
--tl-gantt-row-bg: var(--color-bg-primary, #ffffff);
|
||||
--tl-gantt-row-bg-alt: var(--color-bg-secondary, #f9fafb);
|
||||
--tl-gantt-weekend-bg: rgba(0, 0, 0, 0.02);
|
||||
--tl-gantt-today-line: var(--color-status-error, #ef4444);
|
||||
--tl-gantt-sidebar-width: 240px;
|
||||
--tl-gantt-axis-height: 48px;
|
||||
--tl-gantt-axis-bg: var(--color-bg-secondary, #f9fafb);
|
||||
--tl-gantt-axis-border: var(--color-border-primary, #e5e7eb);
|
||||
|
||||
// Timeline-specific: Activity
|
||||
--tl-activity-unread-dot: var(--color-primary-500, #3b82f6);
|
||||
--tl-activity-avatar-size: 36px;
|
||||
}
|
||||
30
src/types/activity.types.ts
Normal file
30
src/types/activity.types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface TlActivityItem {
|
||||
id: string;
|
||||
actor: TlActor;
|
||||
action: string;
|
||||
target?: string;
|
||||
description?: string;
|
||||
date: Date | string;
|
||||
icon?: string;
|
||||
type?: TlActivityType;
|
||||
unread?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TlActor {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
initials?: string;
|
||||
}
|
||||
|
||||
export type TlActivityType =
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'comment'
|
||||
| 'assign'
|
||||
| 'status'
|
||||
| 'upload'
|
||||
| 'share'
|
||||
| 'system';
|
||||
21
src/types/audit.types.ts
Normal file
21
src/types/audit.types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface TlAuditEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
category: string;
|
||||
user: string;
|
||||
date: Date | string;
|
||||
severity: TlAuditSeverity;
|
||||
description?: string;
|
||||
resource?: string;
|
||||
ipAddress?: string;
|
||||
changes?: TlAuditChange[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TlAuditChange {
|
||||
field: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
}
|
||||
|
||||
export type TlAuditSeverity = 'info' | 'warning' | 'error' | 'critical' | 'debug';
|
||||
22
src/types/changelog.types.ts
Normal file
22
src/types/changelog.types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface TlChangelogVersion {
|
||||
version: string;
|
||||
date: Date | string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
entries: TlChangelogEntry[];
|
||||
}
|
||||
|
||||
export interface TlChangelogEntry {
|
||||
description: string;
|
||||
category: TlChangeCategory;
|
||||
breaking?: boolean;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
export type TlChangeCategory =
|
||||
| 'added'
|
||||
| 'changed'
|
||||
| 'fixed'
|
||||
| 'removed'
|
||||
| 'deprecated'
|
||||
| 'security';
|
||||
39
src/types/config.types.ts
Normal file
39
src/types/config.types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface TlConfig {
|
||||
locale: string;
|
||||
dateFormat: Intl.DateTimeFormatOptions;
|
||||
timeFormat: Intl.DateTimeFormatOptions;
|
||||
relativeTime: boolean;
|
||||
animate: boolean;
|
||||
timeline: {
|
||||
layout: TlTimelineLayout;
|
||||
markerSize: TlMarkerSize;
|
||||
connectorStyle: TlConnectorStyle;
|
||||
sortDirection: TlSortDirection;
|
||||
groupByDate: boolean;
|
||||
};
|
||||
activity: {
|
||||
pageSize: number;
|
||||
showUnreadIndicator: boolean;
|
||||
groupByDate: boolean;
|
||||
};
|
||||
audit: {
|
||||
pageSize: number;
|
||||
searchable: boolean;
|
||||
filterable: boolean;
|
||||
expandable: boolean;
|
||||
showSeverityIcons: boolean;
|
||||
};
|
||||
gantt: {
|
||||
zoomLevel: TlGanttZoomLevel;
|
||||
rowHeight: number;
|
||||
showDependencies: boolean;
|
||||
showProgress: boolean;
|
||||
showTodayLine: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type TlTimelineLayout = 'alternating' | 'left' | 'right';
|
||||
export type TlMarkerSize = 'sm' | 'md' | 'lg';
|
||||
export type TlConnectorStyle = 'solid' | 'dashed' | 'dotted';
|
||||
export type TlSortDirection = 'asc' | 'desc';
|
||||
export type TlGanttZoomLevel = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
86
src/types/event.types.ts
Normal file
86
src/types/event.types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { TlTimelineItem } from './timeline.types';
|
||||
import type { TlActivityItem, TlActor } from './activity.types';
|
||||
import type { TlAuditEntry } from './audit.types';
|
||||
import type { TlChangelogVersion, TlChangelogEntry } from './changelog.types';
|
||||
import type { TlGanttTask } from './gantt.types';
|
||||
|
||||
export interface TlTimelineItemClickEvent {
|
||||
item: TlTimelineItem;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface TlTimelineItemHoverEvent {
|
||||
item: TlTimelineItem;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface TlActivityClickEvent {
|
||||
item: TlActivityItem;
|
||||
}
|
||||
|
||||
export interface TlActorClickEvent {
|
||||
actor: TlActor;
|
||||
}
|
||||
|
||||
export interface TlActivityLoadMoreEvent {
|
||||
currentCount: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface TlAuditEntryClickEvent {
|
||||
entry: TlAuditEntry;
|
||||
}
|
||||
|
||||
export interface TlAuditFilterChangeEvent {
|
||||
searchTerm: string;
|
||||
categories: string[];
|
||||
severities: string[];
|
||||
dateRange: { start?: Date; end?: Date } | null;
|
||||
}
|
||||
|
||||
export interface TlAuditPageChangeEvent {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface TlVersionClickEvent {
|
||||
version: TlChangelogVersion;
|
||||
}
|
||||
|
||||
export interface TlChangelogEntryClickEvent {
|
||||
entry: TlChangelogEntry;
|
||||
version: TlChangelogVersion;
|
||||
}
|
||||
|
||||
export interface TlGanttTaskClickEvent {
|
||||
task: TlGanttTask;
|
||||
}
|
||||
|
||||
export interface TlGanttTaskResizeEvent {
|
||||
task: TlGanttTask;
|
||||
newStartDate?: Date;
|
||||
newEndDate?: Date;
|
||||
}
|
||||
|
||||
export interface TlGanttTaskMoveEvent {
|
||||
task: TlGanttTask;
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
}
|
||||
|
||||
export interface TlGanttZoomChangeEvent {
|
||||
level: string;
|
||||
}
|
||||
|
||||
export interface TlGanttScrollEvent {
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export interface TlSearchChangeEvent {
|
||||
term: string;
|
||||
}
|
||||
|
||||
export interface TlSortToggleEvent {
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
35
src/types/gantt.types.ts
Normal file
35
src/types/gantt.types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface TlGanttTask {
|
||||
id: string;
|
||||
name: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
progress?: number;
|
||||
status?: TlGanttTaskStatus;
|
||||
color?: string;
|
||||
group?: string;
|
||||
parentId?: string;
|
||||
assignee?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TlGanttDependency {
|
||||
fromTaskId: string;
|
||||
toTaskId: string;
|
||||
type?: TlDependencyType;
|
||||
}
|
||||
|
||||
export type TlGanttTaskStatus = 'not-started' | 'in-progress' | 'completed' | 'delayed' | 'on-hold';
|
||||
export type TlDependencyType = 'finish-to-start' | 'start-to-start' | 'finish-to-finish' | 'start-to-finish';
|
||||
|
||||
export interface TlGanttTaskPosition {
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface TlDateTick {
|
||||
date: Date;
|
||||
label: string;
|
||||
position: number;
|
||||
isWeekend: boolean;
|
||||
isMajor: boolean;
|
||||
}
|
||||
7
src/types/index.ts
Normal file
7
src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './config.types';
|
||||
export * from './timeline.types';
|
||||
export * from './activity.types';
|
||||
export * from './audit.types';
|
||||
export * from './changelog.types';
|
||||
export * from './gantt.types';
|
||||
export * from './event.types';
|
||||
19
src/types/timeline.types.ts
Normal file
19
src/types/timeline.types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface TlTimelineItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
date: Date | string;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
status?: TlTimelineStatus;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TlTimelineStatus = 'completed' | 'active' | 'pending' | 'error' | 'warning';
|
||||
|
||||
export interface TlTimelineGroup {
|
||||
label: string;
|
||||
items: TlTimelineItem[];
|
||||
}
|
||||
97
src/utils/date.utils.ts
Normal file
97
src/utils/date.utils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { TlTimelineGroup } from '../types/timeline.types';
|
||||
|
||||
export function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
export function formatDate(value: Date | string, locale = 'en-US', options?: Intl.DateTimeFormatOptions): string {
|
||||
const date = toDate(value);
|
||||
return date.toLocaleDateString(locale, options ?? { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatTime(value: Date | string, locale = 'en-US', options?: Intl.DateTimeFormatOptions): string {
|
||||
const date = toDate(value);
|
||||
return date.toLocaleTimeString(locale, options ?? { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatRelativeTime(value: Date | string): string {
|
||||
const date = toDate(value);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
export function formatSmartTime(value: Date | string, locale = 'en-US'): string {
|
||||
const date = toDate(value);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
|
||||
if (diffHours < 24) return formatRelativeTime(value);
|
||||
if (diffHours < 48) return 'Yesterday ' + formatTime(value, locale);
|
||||
return formatDate(value, locale) + ' ' + formatTime(value, locale);
|
||||
}
|
||||
|
||||
export function getDateLabel(value: Date | string): string {
|
||||
const date = toDate(value);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const diffDays = Math.floor((today.getTime() - target.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
export function groupByDate<T extends { date: Date | string }>(items: T[]): { label: string; items: T[] }[] {
|
||||
const groups = new Map<string, T[]>();
|
||||
|
||||
for (const item of items) {
|
||||
const label = getDateLabel(item.date);
|
||||
const group = groups.get(label);
|
||||
if (group) {
|
||||
group.push(item);
|
||||
} else {
|
||||
groups.set(label, [item]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
export function diffInDays(start: Date | string, end: Date | string): number {
|
||||
const s = toDate(start);
|
||||
const e = toDate(end);
|
||||
return Math.ceil((e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export function isWeekend(date: Date | string): boolean {
|
||||
const d = toDate(date);
|
||||
const day = d.getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
export function isToday(date: Date | string): boolean {
|
||||
const d = toDate(date);
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
export function addDays(date: Date | string, days: number): Date {
|
||||
const d = new Date(toDate(date));
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
77
src/utils/filter.utils.ts
Normal file
77
src/utils/filter.utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { TlAuditEntry, TlAuditSeverity } from '../types/audit.types';
|
||||
import { toDate } from './date.utils';
|
||||
|
||||
export function searchItems<T>(items: T[], term: string, fields: (keyof T)[]): T[] {
|
||||
if (!term.trim()) return items;
|
||||
const lower = term.toLowerCase();
|
||||
|
||||
return items.filter(item =>
|
||||
fields.some(field => {
|
||||
const value = item[field];
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase().includes(lower);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function filterAuditEntries(
|
||||
entries: TlAuditEntry[],
|
||||
options: {
|
||||
searchTerm?: string;
|
||||
categories?: string[];
|
||||
severities?: TlAuditSeverity[];
|
||||
dateRange?: { start?: Date; end?: Date } | null;
|
||||
}
|
||||
): TlAuditEntry[] {
|
||||
let result = entries;
|
||||
|
||||
if (options.searchTerm?.trim()) {
|
||||
result = searchItems(result, options.searchTerm, ['action', 'user', 'description', 'resource', 'category']);
|
||||
}
|
||||
|
||||
if (options.categories?.length) {
|
||||
result = result.filter(e => options.categories!.includes(e.category));
|
||||
}
|
||||
|
||||
if (options.severities?.length) {
|
||||
result = result.filter(e => options.severities!.includes(e.severity));
|
||||
}
|
||||
|
||||
if (options.dateRange) {
|
||||
result = filterByDateRange(result, 'date', options.dateRange.start, options.dateRange.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function filterByField<T>(items: T[], field: keyof T, values: unknown[]): T[] {
|
||||
if (!values.length) return items;
|
||||
return items.filter(item => values.includes(item[field]));
|
||||
}
|
||||
|
||||
export function filterByDateRange<T extends Record<string, any>>(
|
||||
items: T[],
|
||||
dateField: keyof T,
|
||||
start?: Date,
|
||||
end?: Date
|
||||
): T[] {
|
||||
return items.filter(item => {
|
||||
const date = toDate(item[dateField] as Date | string);
|
||||
if (start && date < start) return false;
|
||||
if (end && date > end) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function extractUniqueValues<T>(items: T[], field: keyof T): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const item of items) {
|
||||
const value = item[field];
|
||||
if (typeof value === 'string') {
|
||||
values.add(value);
|
||||
}
|
||||
}
|
||||
return Array.from(values).sort();
|
||||
}
|
||||
131
src/utils/gantt.utils.ts
Normal file
131
src/utils/gantt.utils.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { TlGanttTask, TlGanttDependency, TlGanttTaskPosition, TlDateTick } from '../types/gantt.types';
|
||||
import type { TlGanttZoomLevel } from '../types/config.types';
|
||||
import { toDate, diffInDays, isWeekend, addDays } from './date.utils';
|
||||
|
||||
export function calculateDateRange(tasks: TlGanttTask[]): { start: Date; end: Date } {
|
||||
if (tasks.length === 0) {
|
||||
const now = new Date();
|
||||
return { start: now, end: addDays(now, 30) };
|
||||
}
|
||||
|
||||
let minDate = toDate(tasks[0].startDate);
|
||||
let maxDate = toDate(tasks[0].endDate);
|
||||
|
||||
for (const task of tasks) {
|
||||
const start = toDate(task.startDate);
|
||||
const end = toDate(task.endDate);
|
||||
if (start < minDate) minDate = start;
|
||||
if (end > maxDate) maxDate = end;
|
||||
}
|
||||
|
||||
return {
|
||||
start: addDays(minDate, -2),
|
||||
end: addDays(maxDate, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPixelsPerDay(zoomLevel: TlGanttZoomLevel): number {
|
||||
switch (zoomLevel) {
|
||||
case 'day': return 60;
|
||||
case 'week': return 20;
|
||||
case 'month': return 6;
|
||||
case 'quarter': return 2;
|
||||
case 'year': return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateTicks(start: Date, end: Date, zoomLevel: TlGanttZoomLevel): TlDateTick[] {
|
||||
const ticks: TlDateTick[] = [];
|
||||
const ppd = getPixelsPerDay(zoomLevel);
|
||||
const current = new Date(start);
|
||||
let position = 0;
|
||||
|
||||
while (current <= end) {
|
||||
const isMajor = zoomLevel === 'day'
|
||||
? current.getDay() === 1
|
||||
: zoomLevel === 'week'
|
||||
? current.getDate() === 1
|
||||
: current.getMonth() % 3 === 0 && current.getDate() === 1;
|
||||
|
||||
const label = zoomLevel === 'day' || zoomLevel === 'week'
|
||||
? current.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
: current.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
|
||||
ticks.push({
|
||||
date: new Date(current),
|
||||
label,
|
||||
position,
|
||||
isWeekend: isWeekend(current),
|
||||
isMajor,
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
position += ppd;
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function calculateTaskPosition(
|
||||
task: TlGanttTask,
|
||||
chartStart: Date,
|
||||
pixelsPerDay: number
|
||||
): TlGanttTaskPosition {
|
||||
const start = toDate(task.startDate);
|
||||
const end = toDate(task.endDate);
|
||||
const left = diffInDays(chartStart, start) * pixelsPerDay;
|
||||
const width = Math.max(diffInDays(start, end) * pixelsPerDay, pixelsPerDay);
|
||||
return { left, width };
|
||||
}
|
||||
|
||||
export function calculateChartWidth(start: Date, end: Date, pixelsPerDay: number): number {
|
||||
return diffInDays(start, end) * pixelsPerDay;
|
||||
}
|
||||
|
||||
export function calculateDependencyPath(
|
||||
from: TlGanttTaskPosition,
|
||||
fromRowIndex: number,
|
||||
to: TlGanttTaskPosition,
|
||||
toRowIndex: number,
|
||||
rowHeight: number
|
||||
): string {
|
||||
const startX = from.left + from.width;
|
||||
const startY = fromRowIndex * rowHeight + rowHeight / 2;
|
||||
const endX = to.left;
|
||||
const endY = toRowIndex * rowHeight + rowHeight / 2;
|
||||
const midX = startX + (endX - startX) / 2;
|
||||
|
||||
return `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`;
|
||||
}
|
||||
|
||||
export function sortTasksHierarchically(tasks: TlGanttTask[]): TlGanttTask[] {
|
||||
const roots = tasks.filter(t => !t.parentId);
|
||||
const childMap = new Map<string, TlGanttTask[]>();
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.parentId) {
|
||||
const children = childMap.get(task.parentId) || [];
|
||||
children.push(task);
|
||||
childMap.set(task.parentId, children);
|
||||
}
|
||||
}
|
||||
|
||||
const result: TlGanttTask[] = [];
|
||||
const addWithChildren = (task: TlGanttTask): void => {
|
||||
result.push(task);
|
||||
const children = childMap.get(task.id) || [];
|
||||
for (const child of children) {
|
||||
addWithChildren(child);
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
addWithChildren(root);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function calculateTodayOffset(chartStart: Date, pixelsPerDay: number): number {
|
||||
return diffInDays(chartStart, new Date()) * pixelsPerDay;
|
||||
}
|
||||
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './date.utils';
|
||||
export * from './gantt.utils';
|
||||
export * from './filter.utils';
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"],
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
18
tsconfig.lib.json
Normal file
18
tsconfig.lib.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.stories.ts"
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user