diff --git a/brewman/alembic/versions/fab52fb911e4_voucher_tag_permission.py b/brewman/alembic/versions/fab52fb911e4_voucher_tag_permission.py new file mode 100644 index 00000000..a7777ec0 --- /dev/null +++ b/brewman/alembic/versions/fab52fb911e4_voucher_tag_permission.py @@ -0,0 +1,30 @@ +"""voucher tag permission + +Revision ID: fab52fb911e4 +Revises: c460289ba94d +Create Date: 2024-07-28 16:14:56.020397 + +""" + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "fab52fb911e4" +down_revision = "c460289ba94d" +branch_labels = None +depends_on = None + + +def upgrade(): + permissions = sa.table("permissions", sa.column("id", sa.Uuid), sa.column("name", sa.String)) + op.execute(permissions.insert().values(id="4a43fcf7-c104-4c5d-8f46-7a4b8f997972", name="Tags")) + # ### end Alembic commands ### + + +def downgrade(): + permissions = sa.table("permissions", sa.column("id", sa.Uuid), sa.column("name", sa.String)) + op.execute(permissions.delete().where(permissions.c.id == "4a43fcf7-c104-4c5d-8f46-7a4b8f997972")) + # ### end Alembic commands ### diff --git a/brewman/brewman/routers/reports/ledger.py b/brewman/brewman/routers/reports/ledger.py index 52fae185..e155dcc8 100644 --- a/brewman/brewman/routers/reports/ledger.py +++ b/brewman/brewman/routers/reports/ledger.py @@ -114,6 +114,7 @@ def build_report(account_id: uuid.UUID, start_date: str, finish_date: str, db: S debit=debit, credit=credit, posted=voucher.posted, + tags=[t.name for t in voucher.tags], ) ) return body @@ -146,4 +147,5 @@ def opening_balance(account_id: uuid.UUID, start_date: str, db: Session) -> sche debit=debit, credit=credit, posted=True, + tags=["Opening Balance"], ) diff --git a/brewman/brewman/routers/tag.py b/brewman/brewman/routers/tag.py index 76f8475c..2f07afa0 100644 --- a/brewman/brewman/routers/tag.py +++ b/brewman/brewman/routers/tag.py @@ -2,6 +2,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Security, status from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -14,6 +15,7 @@ from brewman.models.voucher_tag import VoucherTag from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.tag import Tag +from ..schemas.tag_vouchers import TagVouchers from ..schemas.user import UserToken @@ -118,6 +120,38 @@ def show_id( return tag_info(item) +@router.post("/vouchers", response_model=bool) +def tag_vouchers( + data: TagVouchers, + user: UserToken = Security(get_user, scopes=["tags"]), +) -> bool: + try: + with SessionFuture() as db: + old_tags = db.execute(select(Tag.name)).scalars().all() + new_tag = next((t.name for t in data.tags if t.name not in old_tags), None) + if new_tag is not None and new_tag != "": + db.add(Tag(name=new_tag)) + db.flush() + remove = [t.name for t in data.tags if t.enabled is False] + tags = db.execute(select(Tag.id).where(Tag.name.in_(remove))).scalars().all() + db.execute( + delete(VoucherTag).where(VoucherTag.tag_id.in_(tags), VoucherTag.voucher_id.in_(data.voucher_ids)) + ) + + additional = [t.name for t in data.tags if t.enabled is True] + tags = db.execute(select(Tag.id).where(Tag.name.in_(additional))).scalars().all() + for vid in data.voucher_ids: + for t in tags: + db.execute(pg_insert(VoucherTag).values(voucher_id=vid, tag_id=t).on_conflict_do_nothing()) + db.commit() + return True + except SQLAlchemyError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) + + def tag_info(item: Tag) -> schemas.Tag: return schemas.Tag( id_=item.id, diff --git a/brewman/brewman/schemas/ledger.py b/brewman/brewman/schemas/ledger.py index c34d6aa1..3afdd049 100644 --- a/brewman/brewman/schemas/ledger.py +++ b/brewman/brewman/schemas/ledger.py @@ -24,6 +24,7 @@ class LedgerItem(BaseModel): debit: Daf credit: Daf posted: bool + tags: list[str] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @field_validator("date_", mode="before") diff --git a/brewman/brewman/schemas/tag_vouchers.py b/brewman/brewman/schemas/tag_vouchers.py new file mode 100644 index 00000000..75a3e373 --- /dev/null +++ b/brewman/brewman/schemas/tag_vouchers.py @@ -0,0 +1,17 @@ +import uuid + +from pydantic import BaseModel, ConfigDict + +from brewman.schemas import to_camel + + +class TagVouchersItem(BaseModel): + name: str + enabled: bool + model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True) + + +class TagVouchers(BaseModel): + tags: list[TagVouchersItem] + voucher_ids: list[uuid.UUID] + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/overlord/src/app/app.routes.ts b/overlord/src/app/app.routes.ts index 4736d600..67e2e0f9 100644 --- a/overlord/src/app/app.routes.ts +++ b/overlord/src/app/app.routes.ts @@ -153,6 +153,10 @@ export const routes: Routes = [ path: 'stock-movement', loadChildren: () => import('./stock-movement/stock-movement.routes').then((mod) => mod.routes), }, + { + path: 'tags', + loadChildren: () => import('./tag/tag.routes').then((mod) => mod.routes), + }, { path: 'trial-balance', loadChildren: () => import('./trial-balance/trial-balance.routes').then((mod) => mod.routes), diff --git a/overlord/src/app/core/nav-bar/nav-bar.component.html b/overlord/src/app/core/nav-bar/nav-bar.component.html index cda7d2fa..fb5969e1 100644 --- a/overlord/src/app/core/nav-bar/nav-bar.component.html +++ b/overlord/src/app/core/nav-bar/nav-bar.component.html @@ -56,6 +56,7 @@ Recipes Recipe Templates Periods + Tags diff --git a/overlord/src/app/core/voucher.ts b/overlord/src/app/core/voucher.ts index 8bcaec07..bcbfeb02 100644 --- a/overlord/src/app/core/voucher.ts +++ b/overlord/src/app/core/voucher.ts @@ -1,3 +1,4 @@ +import { Tag } from '../tag/tag'; import { Account } from './account'; import { CostCentre } from './cost-centre'; import { DbFile } from './db-file'; @@ -5,7 +6,6 @@ import { EmployeeBenefit } from './employee-benefit'; import { Incentive } from './incentive'; import { Inventory } from './inventory'; import { Journal } from './journal'; -import { Tag } from './tag'; import { User } from './user'; export class Voucher { diff --git a/overlord/src/app/journal/journal.component.ts b/overlord/src/app/journal/journal.component.ts index 1c305d32..0d5ea3a9 100644 --- a/overlord/src/app/journal/journal.component.ts +++ b/overlord/src/app/journal/journal.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/ledger/ledger-datasource.ts b/overlord/src/app/ledger/ledger-datasource.ts index 06caf393..436a003c 100644 --- a/overlord/src/app/ledger/ledger-datasource.ts +++ b/overlord/src/app/ledger/ledger-datasource.ts @@ -2,24 +2,41 @@ 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 { merge, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; import { LedgerItem } from './ledger-item'; /** 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 LedgerDataSource extends DataSource { + public data: LedgerItem[] = []; + private tags: string[] = []; + public paginator?: MatPaginator; + public sort?: MatSort; + public debit = 0; + public credit = 0; + public running = 0; + constructor( - public data: LedgerItem[], - private paginator?: MatPaginator, - private sort?: MatSort, + public dataObs: Observable, + public filter: Observable, ) { super(); } connect(): Observable { const dataMutations: (EventEmitter | EventEmitter)[] = []; + const d = this.dataObs.pipe( + tap((x) => { + this.data = x; + }), + ); + const f = this.filter.pipe( + tap((x) => { + this.tags = x; + }), + ); if (this.paginator) { dataMutations.push((this.paginator as MatPaginator).page); } @@ -27,18 +44,48 @@ export class LedgerDataSource extends DataSource { 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]))), - ); + return merge(d, f, ...dataMutations) + .pipe( + map(() => this.getFilteredData()), + tap((x: LedgerItem[]) => { + if (this.paginator) { + this.paginator.length = x.length; + } + }), + ) + .pipe(map((x: LedgerItem[]) => this.getPagedData(this.getSortedData(x)))); } disconnect() {} + calculateTotals(data: LedgerItem[]) { + this.debit = 0; + this.credit = 0; + this.running = 0; + data.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; + }); + } + + private getFilteredData(): LedgerItem[] { + const fData = this.data.filter( + (x) => + (x.tags.length === 0 && this.tags.indexOf('(None)') !== -1) || + x.tags.filter((t) => this.tags.includes(t)).length > 0, + ); + this.calculateTotals(fData); + return fData; + } + private getPagedData(data: LedgerItem[]) { if (this.paginator === undefined) { return data; diff --git a/overlord/src/app/ledger/ledger-item.ts b/overlord/src/app/ledger/ledger-item.ts index c378f0b0..afd0c3f5 100644 --- a/overlord/src/app/ledger/ledger-item.ts +++ b/overlord/src/app/ledger/ledger-item.ts @@ -9,6 +9,7 @@ export class LedgerItem { running: number; posted: boolean; url: string; + tags: string[]; public constructor(init?: Partial) { 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

+
+
+
+ + Create New + + +
+ @for (t of tags; track t) { +
+ {{ t.name }} +
+ } +
+
+
+ + +
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 + + +
+
+ + Name + + +
+
+
+ + + @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