) {
this.date = '';
@@ -21,6 +22,7 @@ export class LedgerItem {
this.running = 0;
this.posted = true;
this.url = '';
+ this.tags = [];
Object.assign(this, init);
}
}
diff --git a/overlord/src/app/ledger/ledger.component.html b/overlord/src/app/ledger/ledger.component.html
index c615eb48..d85aea04 100644
--- a/overlord/src/app/ledger/ledger.component.html
+++ b/overlord/src/app/ledger/ledger.component.html
@@ -55,12 +55,42 @@
+
+
+ Tags
+
+ @for (tag of tags; track tag) {
+ {{ tag }}
+ }
+
+
+
+
- Date
- {{ row.date }}
+
+
+
+ Date
+
+
+
+
+ {{ row.date }}
+
@@ -82,8 +112,15 @@
- Narration
- {{ row.narration }}
+ Narration
+ {{ row.narration }}
+
+ @for (tag of row.tags; track tag) {
+ {{ tag }}
+ }
+
@@ -91,21 +128,23 @@
Debit
{{ row.debit | currency: 'INR' | clear }}
- {{ debit | currency: 'INR' }}
+ {{ dataSource.debit | currency: 'INR' }}
Credit
{{ row.credit | currency: 'INR' | clear }}
- {{ credit | currency: 'INR' }}
+ {{ dataSource.credit | currency: 'INR' }}
Running
{{ row.running | currency: 'INR' | accounting }}
- {{ running | currency: 'INR' | accounting }}
+ {{
+ dataSource.running | currency: 'INR' | accounting
+ }}
diff --git a/overlord/src/app/ledger/ledger.component.ts b/overlord/src/app/ledger/ledger.component.ts
index 3ae33734..34f2ecf0 100644
--- a/overlord/src/app/ledger/ledger.component.ts
+++ b/overlord/src/app/ledger/ledger.component.ts
@@ -1,5 +1,6 @@
+import { SelectionModel } from '@angular/cdk/collections';
import { AsyncPipe, CurrencyPipe } from '@angular/common';
-import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
+import { AfterViewInit, Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger, MatAutocomplete } from '@angular/material/autocomplete';
import { MatIconButton, MatButton } from '@angular/material/button';
@@ -11,6 +12,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatInput } from '@angular/material/input';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
+import { MatCheckbox } from '@angular/material/checkbox';
import {
MatTable,
MatColumnDef,
@@ -29,7 +31,7 @@ import {
} from '@angular/material/table';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import moment from 'moment';
-import { Observable, of as observableOf } from 'rxjs';
+import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { Account } from '../core/account';
@@ -40,7 +42,13 @@ import { ToCsvService } from '../shared/to-csv.service';
import { Ledger } from './ledger';
import { LedgerDataSource } from './ledger-datasource';
-import { LedgerService } from './ledger.service';
+import { MatSelect } from '@angular/material/select';
+import { MatChip, MatChipSet } from '@angular/material/chips';
+import { LedgerItem } from './ledger-item';
+import { MatDialog } from '@angular/material/dialog';
+import { TagService } from '../tag/tag.service';
+import { Tag } from '../tag/tag';
+import { TagDialogComponent } from '../tag-dialog/tag-dialog.component';
@Component({
selector: 'app-ledger',
@@ -52,6 +60,9 @@ import { LedgerService } from './ledger.service';
MatCardHeader,
MatCardTitleGroup,
MatCardTitle,
+ MatCheckbox,
+ MatChip,
+ MatChipSet,
MatIconButton,
MatIcon,
MatCardContent,
@@ -68,6 +79,7 @@ import { LedgerService } from './ledger.service';
MatOption,
MatButton,
MatTable,
+ MatSelect,
MatSort,
MatColumnDef,
MatHeaderCellDef,
@@ -97,17 +109,21 @@ export class LedgerComponent implements OnInit, AfterViewInit {
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
@ViewChild(MatSort, { static: true }) sort!: MatSort;
info: Ledger = new Ledger();
- dataSource: LedgerDataSource = new LedgerDataSource(this.info.body);
+ body = new BehaviorSubject([]);
+ filter = new BehaviorSubject([]);
+ dataSource: LedgerDataSource = new LedgerDataSource(this.body, this.filter);
+ selection = new SelectionModel(true, []);
+
+ allTags: Tag[] = [];
+ tags: string[] = [];
form: FormGroup<{
startDate: FormControl;
finishDate: FormControl;
account: FormControl;
+ tags: FormControl;
}>;
selectedRowId = '';
- debit = 0;
- credit = 0;
- running = 0;
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['date', 'particulars', 'type', 'narration', 'debit', 'credit', 'running'];
@@ -123,14 +139,16 @@ export class LedgerComponent implements OnInit, AfterViewInit {
constructor(
private route: ActivatedRoute,
private router: Router,
+ private dialog: MatDialog,
private toCsv: ToCsvService,
- private ser: LedgerService,
private accountSer: AccountService,
+ private tagSer: TagService,
) {
this.form = new FormGroup({
startDate: new FormControl(new Date(), { nonNullable: true }),
finishDate: new FormControl(new Date(), { nonNullable: true }),
account: new FormControl(null),
+ tags: new FormControl([], { nonNullable: true }),
});
this.accounts = this.form.controls.account.valueChanges.pipe(
@@ -142,16 +160,29 @@ export class LedgerComponent implements OnInit, AfterViewInit {
ngOnInit() {
this.route.data.subscribe((value) => {
- const data = value as { info: Ledger };
+ const data = value as { tags: Tag[]; info: Ledger };
+ this.allTags = data.tags;
this.info = data.info;
- this.calculateTotals();
+ this.tags = [
+ '(None)',
+ ...Array.from(new Set(this.info.body.map((x) => x.tags).reduce((list, tags) => list.concat(tags), []))),
+ ];
this.form.setValue({
account: this.info.account?.name ?? '',
startDate: moment(this.info.startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info.finishDate, 'DD-MMM-YYYY').toDate(),
+ tags: this.tags,
});
- this.dataSource = new LedgerDataSource(this.info.body, this.paginator, this.sort);
+ this.body.next(this.info.body);
+ this.filter.next(this.tags);
+ if (!this.dataSource.paginator) {
+ this.dataSource.paginator = this.paginator;
+ }
+ if (!this.dataSource.sort) {
+ this.dataSource.sort = this.sort;
+ }
+ this.selection.clear();
});
}
@@ -167,22 +198,8 @@ export class LedgerComponent implements OnInit, AfterViewInit {
return !account ? '' : typeof account === 'string' ? account : account.name;
}
- calculateTotals() {
- this.debit = 0;
- this.credit = 0;
- this.running = 0;
- this.info.body.forEach((item) => {
- if (item.type !== 'Opening Balance') {
- this.debit += item.debit;
- this.credit += item.credit;
- if (item.posted) {
- this.running += item.debit - item.credit;
- }
- } else {
- this.running += item.debit - item.credit;
- }
- item.running = this.running;
- });
+ tagChanges(value: string[]) {
+ this.filter.next(value);
}
selected(event: MatAutocompleteSelectedEvent): void {
@@ -193,6 +210,46 @@ export class LedgerComponent implements OnInit, AfterViewInit {
this.selectedRowId = id;
}
+ /** Whether the number of selected elements matches the total number of rows. */
+ isAllSelected() {
+ const numSelected = this.selection.selected.length;
+ const numRows = this.dataSource.data.length;
+ return numSelected === numRows;
+ }
+
+ /** Selects all rows if they are not all selected; otherwise clear selection. */
+ toggleAllRows() {
+ if (this.isAllSelected()) {
+ this.selection.clear();
+ return;
+ }
+
+ this.selection.select(...this.dataSource.data);
+ }
+
+ labels() {
+ if (!this.selection.selected.length) {
+ return;
+ }
+
+ this.dialog
+ .open(TagDialogComponent, {
+ width: '750px',
+ data: {
+ tags: this.allTags,
+ vouchers: this.selection.selected,
+ },
+ })
+ .afterClosed()
+ .subscribe({
+ next: (result) => {
+ if (result) {
+ this.router.navigateByUrl(this.router.url);
+ }
+ },
+ });
+ }
+
show() {
const info = this.getInfo();
if (info.account) {
diff --git a/overlord/src/app/ledger/ledger.routes.ts b/overlord/src/app/ledger/ledger.routes.ts
index ab4640ad..76323e27 100644
--- a/overlord/src/app/ledger/ledger.routes.ts
+++ b/overlord/src/app/ledger/ledger.routes.ts
@@ -4,6 +4,7 @@ import { authGuard } from '../auth/auth-guard.service';
import { LedgerComponent } from './ledger.component';
import { ledgerResolver } from './ledger.resolver';
+import { tagListResolver } from '../tag/tag-list.resolver';
export const routes: Routes = [
{
@@ -14,6 +15,7 @@ export const routes: Routes = [
permission: 'Ledger',
},
resolve: {
+ tags: tagListResolver,
info: ledgerResolver,
},
runGuardsAndResolvers: 'always',
@@ -26,6 +28,7 @@ export const routes: Routes = [
permission: 'Ledger',
},
resolve: {
+ tags: tagListResolver,
info: ledgerResolver,
},
runGuardsAndResolvers: 'always',
diff --git a/overlord/src/app/payment/payment.component.ts b/overlord/src/app/payment/payment.component.ts
index 700d8a92..a17ac174 100644
--- a/overlord/src/app/payment/payment.component.ts
+++ b/overlord/src/app/payment/payment.component.ts
@@ -45,8 +45,8 @@ import { AccountBalance } from '../core/account-balance';
import { AccountService } from '../core/account.service';
import { DbFile } from '../core/db-file';
import { Journal } from '../core/journal';
-import { Tag } from '../core/tag';
-import { TagService } from '../core/tag.service';
+import { Tag } from '../tag/tag';
+import { TagService } from '../tag/tag.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { User } from '../core/user';
import { Voucher } from '../core/voucher';
diff --git a/overlord/src/app/purchase-return/purchase-return.component.ts b/overlord/src/app/purchase-return/purchase-return.component.ts
index c74b194f..7963f183 100644
--- a/overlord/src/app/purchase-return/purchase-return.component.ts
+++ b/overlord/src/app/purchase-return/purchase-return.component.ts
@@ -46,8 +46,8 @@ import { Batch } from '../core/batch';
import { BatchService } from '../core/batch.service';
import { DbFile } from '../core/db-file';
import { Inventory } from '../core/inventory';
-import { Tag } from '../core/tag';
-import { TagService } from '../core/tag.service';
+import { Tag } from '../tag/tag';
+import { TagService } from '../tag/tag.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { User } from '../core/user';
import { Voucher } from '../core/voucher';
diff --git a/overlord/src/app/purchase/purchase.component.ts b/overlord/src/app/purchase/purchase.component.ts
index 5cfd2e13..175a9209 100644
--- a/overlord/src/app/purchase/purchase.component.ts
+++ b/overlord/src/app/purchase/purchase.component.ts
@@ -47,8 +47,8 @@ import { DbFile } from '../core/db-file';
import { Inventory } from '../core/inventory';
import { Product } from '../core/product';
import { ProductSku } from '../core/product-sku';
-import { Tag } from '../core/tag';
-import { TagService } from '../core/tag.service';
+import { Tag } from '../tag/tag';
+import { TagService } from '../tag/tag.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { User } from '../core/user';
import { Voucher } from '../core/voucher';
diff --git a/overlord/src/app/receipt/receipt.component.ts b/overlord/src/app/receipt/receipt.component.ts
index c7ecf10e..2967c8f1 100644
--- a/overlord/src/app/receipt/receipt.component.ts
+++ b/overlord/src/app/receipt/receipt.component.ts
@@ -45,8 +45,8 @@ import { AccountBalance } from '../core/account-balance';
import { AccountService } from '../core/account.service';
import { DbFile } from '../core/db-file';
import { Journal } from '../core/journal';
-import { Tag } from '../core/tag';
-import { TagService } from '../core/tag.service';
+import { Tag } from '../tag/tag';
+import { TagService } from '../tag/tag.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { User } from '../core/user';
import { Voucher } from '../core/voucher';
diff --git a/overlord/src/app/tag-dialog/tag-dialog.component.css b/overlord/src/app/tag-dialog/tag-dialog.component.css
new file mode 100644
index 00000000..e69de29b
diff --git a/overlord/src/app/tag-dialog/tag-dialog.component.html b/overlord/src/app/tag-dialog/tag-dialog.component.html
new file mode 100644
index 00000000..25345b29
--- /dev/null
+++ b/overlord/src/app/tag-dialog/tag-dialog.component.html
@@ -0,0 +1,26 @@
+Choose Tags
+
+
+
+
+
diff --git a/overlord/src/app/tag-dialog/tag-dialog.component.spec.ts b/overlord/src/app/tag-dialog/tag-dialog.component.spec.ts
new file mode 100644
index 00000000..26727564
--- /dev/null
+++ b/overlord/src/app/tag-dialog/tag-dialog.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TagDialogComponent } from './tag-dialog.component';
+
+describe('TagDialogComponent', () => {
+ let component: TagDialogComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TagDialogComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TagDialogComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/overlord/src/app/tag-dialog/tag-dialog.component.ts b/overlord/src/app/tag-dialog/tag-dialog.component.ts
new file mode 100644
index 00000000..eee05d39
--- /dev/null
+++ b/overlord/src/app/tag-dialog/tag-dialog.component.ts
@@ -0,0 +1,119 @@
+import { CdkScrollable } from '@angular/cdk/scrolling';
+import { AsyncPipe, CurrencyPipe } from '@angular/common';
+import { Component, Inject, OnInit } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { MatAutocompleteTrigger, MatAutocomplete } from '@angular/material/autocomplete';
+import { MatButton } from '@angular/material/button';
+import { MatOption } from '@angular/material/core';
+import { MatDatepicker } from '@angular/material/datepicker';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogRef,
+ MatDialogTitle,
+ MatDialogContent,
+ MatDialogActions,
+ MatDialogClose,
+} from '@angular/material/dialog';
+import { MatFormField, MatLabel, MatHint, MatPrefix } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { MatSelect } from '@angular/material/select';
+
+import { Tag } from '../tag/tag';
+import { AccountingPipe } from '../shared/accounting.pipe';
+import { MatCheckbox } from '@angular/material/checkbox';
+import { LedgerItem } from '../ledger/ledger-item';
+import { TagList } from './tag-list';
+import { TagService } from '../tag/tag.service';
+import { MatSnackBar } from '@angular/material/snack-bar';
+
+@Component({
+ selector: 'app-tag-dialog',
+ templateUrl: './tag-dialog.component.html',
+ styleUrls: ['./tag-dialog.component.css'],
+ standalone: true,
+ imports: [
+ AccountingPipe,
+ AsyncPipe,
+ CurrencyPipe,
+ CdkScrollable,
+ MatCheckbox,
+ MatDialogTitle,
+ MatDialogContent,
+ ReactiveFormsModule,
+ MatFormField,
+ MatSelect,
+ MatDatepicker,
+ MatOption,
+ MatLabel,
+ MatInput,
+ MatAutocompleteTrigger,
+ MatHint,
+ MatAutocomplete,
+ MatPrefix,
+ MatDialogActions,
+ MatButton,
+ MatDialogClose,
+ ],
+})
+export class TagDialogComponent implements OnInit {
+ tags: TagList[] = [];
+ form: FormGroup<{
+ name: FormControl;
+ }>;
+
+ constructor(
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: { tags: Tag[]; vouchers: LedgerItem[] },
+
+ private snackBar: MatSnackBar,
+ private ser: TagService,
+ ) {
+ this.form = new FormGroup({
+ name: new FormControl(''),
+ });
+ }
+
+ ngOnInit() {
+ this.form.patchValue({
+ name: null,
+ });
+ console.log(this.data);
+
+ this.tags = this.data.tags.map((x) => {
+ const v = this.data.vouchers.filter((z) => z.tags.indexOf(x.name) > -1).length;
+
+ switch (true) {
+ case v === 0:
+ return new TagList({ id: x.id, name: x.name, checked: false, indeterminate: false });
+ case v === this.data.vouchers.length:
+ return new TagList({ id: x.id, name: x.name, checked: true, indeterminate: false });
+ default:
+ return new TagList({ id: x.id, name: x.name, checked: false, indeterminate: true });
+ }
+ });
+ }
+
+ update(checked: boolean, tag: TagList) {
+ tag.indeterminate = false;
+ tag.checked = checked;
+ console.log(this.tags);
+ }
+
+ accept(): void {
+ const tags = this.tags.filter((x) => !x.indeterminate).map((x) => ({ name: x.name, enabled: x.checked }));
+ const name = this.form.value.name;
+ if (name) {
+ tags.push({ name: name, enabled: true });
+ }
+ const vouchers = this.data.vouchers.map((x) => x.id);
+ this.ser.vouchers(tags, vouchers).subscribe({
+ next: () => {
+ this.snackBar.open('', 'Success');
+ this.dialogRef.close(true);
+ },
+ error: (error) => {
+ this.snackBar.open(error, 'Danger');
+ },
+ });
+ }
+}
diff --git a/overlord/src/app/tag-dialog/tag-list.ts b/overlord/src/app/tag-dialog/tag-list.ts
new file mode 100644
index 00000000..3d772471
--- /dev/null
+++ b/overlord/src/app/tag-dialog/tag-list.ts
@@ -0,0 +1,14 @@
+export class TagList {
+ id: string | null;
+ name: string;
+ indeterminate: boolean;
+ checked: boolean;
+
+ public constructor(init?: Partial) {
+ this.id = null;
+ this.name = '';
+ this.indeterminate = false;
+ this.checked = false;
+ Object.assign(this, init);
+ }
+}
diff --git a/overlord/src/app/tag/tag-detail/tag-detail.component.css b/overlord/src/app/tag/tag-detail/tag-detail.component.css
new file mode 100644
index 00000000..e69de29b
diff --git a/overlord/src/app/tag/tag-detail/tag-detail.component.html b/overlord/src/app/tag/tag-detail/tag-detail.component.html
new file mode 100644
index 00000000..67feaede
--- /dev/null
+++ b/overlord/src/app/tag/tag-detail/tag-detail.component.html
@@ -0,0 +1,21 @@
+
+
+ Tag
+
+
+
+
+
+
+ @if (!!item.id) {
+
+ }
+
+
diff --git a/overlord/src/app/tag/tag-detail/tag-detail.component.spec.ts b/overlord/src/app/tag/tag-detail/tag-detail.component.spec.ts
new file mode 100644
index 00000000..a85f2160
--- /dev/null
+++ b/overlord/src/app/tag/tag-detail/tag-detail.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TagDetailComponent } from './tag-detail.component';
+
+describe('TagDetailComponent', () => {
+ let component: TagDetailComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TagDetailComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TagDetailComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/overlord/src/app/tag/tag-detail/tag-detail.component.ts b/overlord/src/app/tag/tag-detail/tag-detail.component.ts
new file mode 100644
index 00000000..acb546fb
--- /dev/null
+++ b/overlord/src/app/tag/tag-detail/tag-detail.component.ts
@@ -0,0 +1,116 @@
+import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { MatButton } from '@angular/material/button';
+import { MatCard, MatCardHeader, MatCardTitle, MatCardContent, MatCardActions } from '@angular/material/card';
+import { MatFormField, MatLabel } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { Tag } from '../tag';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { TagService } from '../tag.service';
+import { MatDialog } from '@angular/material/dialog';
+import { ConfirmDialogComponent } from 'src/app/shared/confirm-dialog/confirm-dialog.component';
+
+@Component({
+ selector: 'app-tag-detail',
+ templateUrl: './tag-detail.component.html',
+ styleUrls: ['./tag-detail.component.css'],
+ standalone: true,
+ imports: [
+ MatCard,
+ MatCardHeader,
+ MatCardTitle,
+ MatCardContent,
+ ReactiveFormsModule,
+ MatFormField,
+ MatLabel,
+ MatInput,
+ MatCardActions,
+ MatButton,
+ ],
+})
+export class TagDetailComponent implements OnInit, AfterViewInit {
+ @ViewChild('nameElement', { static: true }) nameElement!: ElementRef;
+ form: FormGroup<{
+ name: FormControl;
+ }>;
+
+ item: Tag = new Tag();
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private snackBar: MatSnackBar,
+ private dialog: MatDialog,
+ private ser: TagService,
+ ) {
+ this.form = new FormGroup({
+ name: new FormControl(null),
+ });
+ }
+
+ ngOnInit() {
+ this.route.data.subscribe((value) => {
+ const data = value as { item: Tag };
+
+ this.showItem(data.item);
+ });
+ }
+
+ showItem(item: Tag) {
+ this.item = item;
+ this.form.setValue({
+ name: this.item.name,
+ });
+ }
+
+ ngAfterViewInit() {
+ setTimeout(() => {
+ this.nameElement.nativeElement.focus();
+ }, 0);
+ }
+
+ save() {
+ this.ser.saveOrUpdate(this.getItem()).subscribe({
+ next: () => {
+ this.snackBar.open('', 'Success');
+ this.router.navigateByUrl('/tags');
+ },
+ error: (error) => {
+ this.snackBar.open(error, 'Danger');
+ },
+ });
+ }
+
+ delete() {
+ this.ser.delete(this.item.id as string).subscribe({
+ next: () => {
+ this.snackBar.open('', 'Success');
+ this.router.navigateByUrl('/tags');
+ },
+ error: (error) => {
+ this.snackBar.open(error, 'Danger');
+ },
+ });
+ }
+
+ confirmDelete(): void {
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '250px',
+ data: { title: 'Delete Tag?', content: 'Are you sure? This cannot be undone.' },
+ });
+
+ dialogRef.afterClosed().subscribe((result: boolean) => {
+ if (result) {
+ this.delete();
+ }
+ });
+ }
+
+ getItem(): Tag {
+ const formModel = this.form.value;
+ this.item.name = formModel.name ?? '';
+ return this.item;
+ }
+}
diff --git a/overlord/src/app/tag/tag-list.resolver.spec.ts b/overlord/src/app/tag/tag-list.resolver.spec.ts
new file mode 100644
index 00000000..9bb63d9b
--- /dev/null
+++ b/overlord/src/app/tag/tag-list.resolver.spec.ts
@@ -0,0 +1,19 @@
+import { TestBed } from '@angular/core/testing';
+import { ResolveFn } from '@angular/router';
+
+import { Tag } from '../tag/tag';
+
+import { tagListResolver } from './tag-list.resolver';
+
+describe('tagListResolver', () => {
+ const executeResolver: ResolveFn = (...resolverParameters) =>
+ TestBed.runInInjectionContext(() => tagListResolver(...resolverParameters));
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ });
+
+ it('should be created', () => {
+ expect(executeResolver).toBeTruthy();
+ });
+});
diff --git a/overlord/src/app/tag/tag-list.resolver.ts b/overlord/src/app/tag/tag-list.resolver.ts
new file mode 100644
index 00000000..b2f59298
--- /dev/null
+++ b/overlord/src/app/tag/tag-list.resolver.ts
@@ -0,0 +1,10 @@
+import { inject } from '@angular/core';
+import { ResolveFn } from '@angular/router';
+
+import { Tag } from './tag';
+
+import { TagService } from './tag.service';
+
+export const tagListResolver: ResolveFn = () => {
+ return inject(TagService).list();
+};
diff --git a/overlord/src/app/tag/tag-list/tag-list-datasource.ts b/overlord/src/app/tag/tag-list/tag-list-datasource.ts
new file mode 100644
index 00000000..28fe0527
--- /dev/null
+++ b/overlord/src/app/tag/tag-list/tag-list-datasource.ts
@@ -0,0 +1,69 @@
+import { DataSource } from '@angular/cdk/collections';
+import { EventEmitter } from '@angular/core';
+import { MatPaginator, PageEvent } from '@angular/material/paginator';
+import { MatSort, Sort } from '@angular/material/sort';
+import { merge, Observable, of as observableOf } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { Tag } from '../tag';
+
+/** Simple sort comparator for example ID/Name columns (for client-side sorting). */
+const compare = (a: string | number, b: string | number, isAsc: boolean) => (a < b ? -1 : 1) * (isAsc ? 1 : -1);
+export class TagListDataSource extends DataSource {
+ constructor(
+ public data: Tag[],
+ private paginator?: MatPaginator,
+ private sort?: MatSort,
+ ) {
+ super();
+ }
+
+ connect(): Observable {
+ const dataMutations: (EventEmitter | EventEmitter)[] = [];
+ if (this.paginator) {
+ dataMutations.push((this.paginator as MatPaginator).page);
+ }
+ if (this.sort) {
+ dataMutations.push((this.sort as MatSort).sortChange);
+ }
+
+ // Set the paginators length
+ if (this.paginator) {
+ this.paginator.length = this.data.length;
+ }
+
+ return merge(observableOf(this.data), ...dataMutations).pipe(
+ map(() => this.getPagedData(this.getSortedData([...this.data]))),
+ );
+ }
+
+ disconnect() {}
+
+ private getPagedData(data: Tag[]) {
+ if (this.paginator === undefined) {
+ return data;
+ }
+ const startIndex = this.paginator.pageIndex * this.paginator.pageSize;
+ return data.splice(startIndex, this.paginator.pageSize);
+ }
+
+ private getSortedData(data: Tag[]) {
+ if (this.sort === undefined) {
+ return data;
+ }
+ if (!this.sort.active || this.sort.direction === '') {
+ return data;
+ }
+
+ const sort = this.sort as MatSort;
+ return data.sort((a, b) => {
+ const isAsc = sort.direction === 'asc';
+ switch (sort.active) {
+ case 'name':
+ return compare(a.name, b.name, isAsc);
+ default:
+ return 0;
+ }
+ });
+ }
+}
diff --git a/overlord/src/app/tag/tag-list/tag-list.component.css b/overlord/src/app/tag/tag-list/tag-list.component.css
new file mode 100644
index 00000000..e69de29b
diff --git a/overlord/src/app/tag/tag-list/tag-list.component.html b/overlord/src/app/tag/tag-list/tag-list.component.html
new file mode 100644
index 00000000..d2939561
--- /dev/null
+++ b/overlord/src/app/tag/tag-list/tag-list.component.html
@@ -0,0 +1,34 @@
+
+
+
+ Tags
+
+ add_box
+ Add
+
+
+
+
+
+
+
+ Name
+ {{ row.name }}
+
+
+
+
+
+
+
+
+
+
diff --git a/overlord/src/app/tag/tag-list/tag-list.component.spec.ts b/overlord/src/app/tag/tag-list/tag-list.component.spec.ts
new file mode 100644
index 00000000..de01bce5
--- /dev/null
+++ b/overlord/src/app/tag/tag-list/tag-list.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
+
+import { TagListComponent } from './tag-list.component';
+
+describe('TagListComponent', () => {
+ let component: TagListComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [RouterTestingModule, TagListComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TagListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ it('should compile', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/overlord/src/app/tag/tag-list/tag-list.component.ts b/overlord/src/app/tag/tag-list/tag-list.component.ts
new file mode 100644
index 00000000..d5d7327a
--- /dev/null
+++ b/overlord/src/app/tag/tag-list/tag-list.component.ts
@@ -0,0 +1,72 @@
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { MatAnchor } from '@angular/material/button';
+import { MatCard, MatCardHeader, MatCardTitleGroup, MatCardTitle, MatCardContent } from '@angular/material/card';
+import { MatIcon } from '@angular/material/icon';
+import { MatPaginator } from '@angular/material/paginator';
+import { MatSort, MatSortHeader } from '@angular/material/sort';
+import {
+ MatTable,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow,
+} from '@angular/material/table';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+
+import { Tag } from '../tag';
+
+import { TagListDataSource } from './tag-list-datasource';
+
+@Component({
+ selector: 'app-tag-list',
+ templateUrl: './tag-list.component.html',
+ styleUrls: ['./tag-list.component.css'],
+ standalone: true,
+ imports: [
+ MatCard,
+ MatCardHeader,
+ MatCardTitleGroup,
+ MatCardTitle,
+ MatAnchor,
+ RouterLink,
+ MatIcon,
+ MatCardContent,
+ MatTable,
+ MatSort,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatSortHeader,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow,
+ MatPaginator,
+ ],
+})
+export class TagListComponent implements OnInit {
+ @ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
+ @ViewChild(MatSort, { static: true }) sort!: MatSort;
+ list: Tag[] = [];
+ dataSource: TagListDataSource = new TagListDataSource(this.list, this.paginator, this.sort);
+ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
+ displayedColumns = ['name'];
+
+ constructor(private route: ActivatedRoute) {}
+
+ ngOnInit() {
+ this.route.data.subscribe((value) => {
+ const data = value as { list: Tag[] };
+
+ this.list = data.list;
+ });
+ this.dataSource = new TagListDataSource(this.list, this.paginator, this.sort);
+ }
+}
diff --git a/overlord/src/app/tag/tag.resolver.spec.ts b/overlord/src/app/tag/tag.resolver.spec.ts
new file mode 100644
index 00000000..e2aec4c4
--- /dev/null
+++ b/overlord/src/app/tag/tag.resolver.spec.ts
@@ -0,0 +1,19 @@
+import { TestBed } from '@angular/core/testing';
+import { ResolveFn } from '@angular/router';
+
+import { Tag } from '../tag/tag';
+
+import { tagResolver } from './tag.resolver';
+
+describe('tagResolver', () => {
+ const executeResolver: ResolveFn = (...resolverParameters) =>
+ TestBed.runInInjectionContext(() => tagResolver(...resolverParameters));
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ });
+
+ it('should be created', () => {
+ expect(executeResolver).toBeTruthy();
+ });
+});
diff --git a/overlord/src/app/tag/tag.resolver.ts b/overlord/src/app/tag/tag.resolver.ts
new file mode 100644
index 00000000..3569f6d8
--- /dev/null
+++ b/overlord/src/app/tag/tag.resolver.ts
@@ -0,0 +1,11 @@
+import { inject } from '@angular/core';
+import { ResolveFn } from '@angular/router';
+
+import { Tag } from './tag';
+
+import { TagService } from './tag.service';
+
+export const tagResolver: ResolveFn = (route) => {
+ const id = route.paramMap.get('id');
+ return inject(TagService).get(id);
+};
diff --git a/overlord/src/app/tag/tag.routes.ts b/overlord/src/app/tag/tag.routes.ts
new file mode 100644
index 00000000..7f1aeb4d
--- /dev/null
+++ b/overlord/src/app/tag/tag.routes.ts
@@ -0,0 +1,44 @@
+import { Routes } from '@angular/router';
+
+import { authGuard } from '../auth/auth-guard.service';
+
+import { TagDetailComponent } from './tag-detail/tag-detail.component';
+import { TagListComponent } from './tag-list/tag-list.component';
+import { tagListResolver } from './tag-list.resolver';
+import { tagResolver } from './tag.resolver';
+
+export const routes: Routes = [
+ {
+ path: '',
+ component: TagListComponent,
+ canActivate: [authGuard],
+ data: {
+ permission: 'Tags',
+ },
+ resolve: {
+ list: tagListResolver,
+ },
+ },
+ {
+ path: 'new',
+ component: TagDetailComponent,
+ canActivate: [authGuard],
+ data: {
+ permission: 'Tags',
+ },
+ resolve: {
+ item: tagResolver,
+ },
+ },
+ {
+ path: ':id',
+ component: TagDetailComponent,
+ canActivate: [authGuard],
+ data: {
+ permission: 'Tags',
+ },
+ resolve: {
+ item: tagResolver,
+ },
+ },
+];
diff --git a/overlord/src/app/tag/tag.service.spec.ts b/overlord/src/app/tag/tag.service.spec.ts
new file mode 100644
index 00000000..c5e5ae16
--- /dev/null
+++ b/overlord/src/app/tag/tag.service.spec.ts
@@ -0,0 +1,17 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
+import { inject, TestBed } from '@angular/core/testing';
+
+import { TagService } from './tag.service';
+
+describe('TagService', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [],
+ providers: [TagService, provideHttpClient(withInterceptorsFromDi())],
+ });
+ });
+
+ it('should be created', inject([TagService], (service: TagService) => {
+ expect(service).toBeTruthy();
+ }));
+});
diff --git a/overlord/src/app/core/tag.service.ts b/overlord/src/app/tag/tag.service.ts
similarity index 57%
rename from overlord/src/app/core/tag.service.ts
rename to overlord/src/app/tag/tag.service.ts
index 2429b550..d2a0c08f 100644
--- a/overlord/src/app/core/tag.service.ts
+++ b/overlord/src/app/tag/tag.service.ts
@@ -3,8 +3,8 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { catchError } from 'rxjs/operators';
-import { ErrorLoggerService } from './error-logger.service';
import { Tag } from './tag';
+import { ErrorLoggerService } from '../core/error-logger.service';
const url = '/api/tags';
const serviceName = 'TagService';
@@ -18,16 +18,17 @@ export class TagService {
private log: ErrorLoggerService,
) {}
- get(id: string): Observable {
+ get(id: string | null): Observable {
+ const getUrl: string = id === null ? `${url}` : `${url}/${id}`;
return this.http
- .get(`${url}/${id}`)
- .pipe(catchError(this.log.handleError(serviceName, 'Get Tag'))) as Observable;
+ .get(getUrl)
+ .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable;
}
list(): Observable {
return this.http
.get(`${url}/list`)
- .pipe(catchError(this.log.handleError(serviceName, 'List Tag'))) as Observable;
+ .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable;
}
autocomplete(query: string): Observable {
@@ -37,10 +38,16 @@ export class TagService {
.pipe(catchError(this.log.handleError(serviceName, 'autocomplete'))) as Observable;
}
- delete(id: string): Observable {
+ save(tag: Tag): Observable {
return this.http
- .delete(`${url}/${id}`)
- .pipe(catchError(this.log.handleError(serviceName, 'Delete Tag'))) as Observable;
+ .post(`${url}`, tag)
+ .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable;
+ }
+
+ update(tag: Tag): Observable {
+ return this.http
+ .put(`${url}/${tag.id}`, tag)
+ .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable;
}
saveOrUpdate(tag: Tag): Observable {
@@ -50,15 +57,15 @@ export class TagService {
return this.update(tag);
}
- save(tag: Tag): Observable {
+ delete(id: string): Observable {
return this.http
- .post(`${url}`, tag)
- .pipe(catchError(this.log.handleError(serviceName, 'Save Tag'))) as Observable;
+ .delete(`${url}/${id}`)
+ .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable;
}
- update(tag: Tag): Observable {
+ vouchers(tags: { name: string; enabled: boolean }[], voucherIds: string[]): Observable {
return this.http
- .put(`${url}/${tag.id}`, tag)
- .pipe(catchError(this.log.handleError(serviceName, 'Update Tag'))) as Observable;
+ .post(`${url}/vouchers`, { tags: tags, voucherIds: voucherIds })
+ .pipe(catchError(this.log.handleError(serviceName, 'Set Voucher Tags'))) as Observable;
}
}
diff --git a/overlord/src/app/core/tag.ts b/overlord/src/app/tag/tag.ts
similarity index 100%
rename from overlord/src/app/core/tag.ts
rename to overlord/src/app/tag/tag.ts