Feature: Filtering and tagging now available in Ledger

This commit is contained in:
Amritanshu Agrawal 2024-07-28 17:32:16 +05:30
parent 336a8f59c5
commit c0eca311c7
40 changed files with 973 additions and 72 deletions

@ -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 ###

@ -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"],
)

@ -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,

@ -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")

@ -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)

@ -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),

@ -56,6 +56,7 @@
<a mat-menu-item routerLink="/recipes">Recipes</a>
<a mat-menu-item routerLink="/recipe-templates">Recipe Templates</a>
<a mat-menu-item routerLink="/periods">Periods</a>
<a mat-menu-item routerLink="/tags">Tags</a>
</mat-menu>
<button mat-button [matMenuTriggerFor]="masterMenu">Masters</button>

@ -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 {

@ -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';

@ -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<LedgerItem> {
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<LedgerItem[]>,
public filter: Observable<string[]>,
) {
super();
}
connect(): Observable<LedgerItem[]> {
const dataMutations: (EventEmitter<PageEvent> | EventEmitter<Sort>)[] = [];
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<LedgerItem> {
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;

@ -9,6 +9,7 @@ export class LedgerItem {
running: number;
posted: boolean;
url: string;
tags: string[];
public constructor(init?: Partial<LedgerItem>) {
this.date = '';
@ -21,6 +22,7 @@ export class LedgerItem {
this.running = 0;
this.posted = true;
this.url = '';
this.tags = [];
Object.assign(this, init);
}
}

@ -55,12 +55,42 @@
</mat-form-field>
<button mat-raised-button class="flex-auto basis-1/5" color="primary" (click)="show()">Show</button>
</div>
<div class="flex flex-row justify-around content-start items-start sm:max-lg:flex-col">
<mat-form-field class="flex-auto basis-4/5 mr-5">
<mat-label>Tags</mat-label>
<mat-select formControlName="tags" multiple (selectionChange)="tagChanges($event.value)">
@for (tag of tags; track tag) {
<mat-option [value]="tag">{{ tag }}</mat-option>
}
</mat-select>
</mat-form-field>
<button mat-raised-button tabindex="-1" extended class="flex-auto basis-1/5" (click)="labels()">
<mat-icon>label</mat-icon>
Manage Labels
</button>
</div>
</form>
<mat-table #table [dataSource]="dataSource" matSort aria-label="Elements">
<!-- Date Column -->
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef mat-sort-header>Date</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.date }}</mat-cell>
<mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? toggleAllRows() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
Date
</mat-header-cell>
<mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
>
</mat-checkbox>
{{ row.date }}
</mat-cell>
<mat-footer-cell *matFooterCellDef></mat-footer-cell>
</ng-container>
@ -82,8 +112,15 @@
<!-- Narration Column -->
<ng-container matColumnDef="narration">
<mat-header-cell *matHeaderCellDef mat-sort-header>Narration</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.narration }}</mat-cell>
<mat-header-cell *matHeaderCellDef>Narration</mat-header-cell>
<mat-cell *matCellDef="let row"
>{{ row.narration }}
<mat-chip-set>
@for (tag of row.tags; track tag) {
<mat-chip>{{ tag }}</mat-chip>
}
</mat-chip-set></mat-cell
>
<mat-footer-cell *matFooterCellDef></mat-footer-cell>
</ng-container>
@ -91,21 +128,23 @@
<ng-container matColumnDef="debit">
<mat-header-cell *matHeaderCellDef mat-sort-header class="right">Debit</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">{{ row.debit | currency: 'INR' | clear }}</mat-cell>
<mat-footer-cell *matFooterCellDef class="right">{{ debit | currency: 'INR' }}</mat-footer-cell>
<mat-footer-cell *matFooterCellDef class="right">{{ dataSource.debit | currency: 'INR' }}</mat-footer-cell>
</ng-container>
<!-- Credit Column -->
<ng-container matColumnDef="credit">
<mat-header-cell *matHeaderCellDef mat-sort-header class="right">Credit</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">{{ row.credit | currency: 'INR' | clear }}</mat-cell>
<mat-footer-cell *matFooterCellDef class="right">{{ credit | currency: 'INR' }}</mat-footer-cell>
<mat-footer-cell *matFooterCellDef class="right">{{ dataSource.credit | currency: 'INR' }}</mat-footer-cell>
</ng-container>
<!-- Running Column -->
<ng-container matColumnDef="running">
<mat-header-cell *matHeaderCellDef class="right">Running</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">{{ row.running | currency: 'INR' | accounting }}</mat-cell>
<mat-footer-cell *matFooterCellDef class="right">{{ running | currency: 'INR' | accounting }}</mat-footer-cell>
<mat-footer-cell *matFooterCellDef class="right">{{
dataSource.running | currency: 'INR' | accounting
}}</mat-footer-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>

@ -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<LedgerItem[]>([]);
filter = new BehaviorSubject<string[]>([]);
dataSource: LedgerDataSource = new LedgerDataSource(this.body, this.filter);
selection = new SelectionModel<LedgerItem>(true, []);
allTags: Tag[] = [];
tags: string[] = [];
form: FormGroup<{
startDate: FormControl<Date>;
finishDate: FormControl<Date>;
account: FormControl<string | null>;
tags: FormControl<string[]>;
}>;
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<string | null>(null),
tags: new FormControl<string[]>([], { 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) {

@ -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',

@ -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';

@ -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';

@ -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';

@ -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';

@ -0,0 +1,26 @@
<h1 mat-dialog-title>Choose Tags</h1>
<div mat-dialog-content>
<form [formGroup]="form" class="flex flex-col">
<div class="flex flex-row justify-around content-start items-start sm:max-lg:flex-col">
<mat-form-field class="flex-auto">
<mat-label>Create New</mat-label>
<input type="text" matInput formControlName="name" autocomplete="off" />
</mat-form-field>
</div>
@for (t of tags; track t) {
<div class="flex flex-row justify-around content-start items-start">
<mat-checkbox
class="flex-auto"
[indeterminate]="t.indeterminate"
[checked]="t.checked"
(change)="update($event.checked, t)"
>{{ t.name }}</mat-checkbox
>
</div>
}
</form>
</div>
<div mat-dialog-actions>
<button mat-raised-button color="warn" [mat-dialog-close]="false" cdkFocusInitial class="mr-5">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
</div>

@ -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<TagDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TagDialogComponent],
}).compileComponents();
fixture = TestBed.createComponent(TagDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -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<string | null>;
}>;
constructor(
public dialogRef: MatDialogRef<TagDialogComponent>,
@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');
},
});
}
}

@ -0,0 +1,14 @@
export class TagList {
id: string | null;
name: string;
indeterminate: boolean;
checked: boolean;
public constructor(init?: Partial<TagList>) {
this.id = null;
this.name = '';
this.indeterminate = false;
this.checked = false;
Object.assign(this, init);
}
}

@ -0,0 +1,21 @@
<mat-card class="lg:max-w-[50%]">
<mat-card-header>
<mat-card-title>Tag</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" class="flex flex-col">
<div class="flex flex-row justify-around content-start items-start">
<mat-form-field class="flex-auto">
<mat-label>Name</mat-label>
<input matInput #nameElement formControlName="name" (keyup.enter)="save()" />
</mat-form-field>
</div>
</form>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button (click)="save()" color="primary" class="mr-5">Save</button>
@if (!!item.id) {
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
}
</mat-card-actions>
</mat-card>

@ -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<TagDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TagDetailComponent],
}).compileComponents();
fixture = TestBed.createComponent(TagDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -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<HTMLInputElement>;
form: FormGroup<{
name: FormControl<string | null>;
}>;
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<string | null>(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;
}
}

@ -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<Tag[]> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => tagListResolver(...resolverParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
});

@ -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<Tag[]> = () => {
return inject(TagService).list();
};

@ -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<Tag> {
constructor(
public data: Tag[],
private paginator?: MatPaginator,
private sort?: MatSort,
) {
super();
}
connect(): Observable<Tag[]> {
const dataMutations: (EventEmitter<PageEvent> | EventEmitter<Sort>)[] = [];
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;
}
});
}
}

@ -0,0 +1,34 @@
<mat-card>
<mat-card-header>
<mat-card-title-group>
<mat-card-title>Tags</mat-card-title>
<a mat-button [routerLink]="['/tags', 'new']">
<mat-icon>add_box</mat-icon>
Add
</a>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<mat-table #table [dataSource]="dataSource" matSort aria-label="Elements">
<!-- Name Column -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>
<mat-cell *matCellDef="let row"
><a [routerLink]="['/tags', row.id]">{{ row.name }}</a></mat-cell
>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
<mat-paginator
#paginator
[length]="dataSource.data.length"
[pageIndex]="0"
[pageSize]="50"
[pageSizeOptions]="[25, 50, 100, 250]"
>
</mat-paginator>
</mat-card-content>
</mat-card>

@ -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<TagListComponent>;
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule, TagListComponent],
}).compileComponents();
fixture = TestBed.createComponent(TagListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should compile', () => {
expect(component).toBeTruthy();
});
});

@ -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);
}
}

@ -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<Tag> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => tagResolver(...resolverParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
});

@ -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<Tag> = (route) => {
const id = route.paramMap.get('id');
return inject(TagService).get(id);
};

@ -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,
},
},
];

@ -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();
}));
});

@ -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<Tag> {
get(id: string | null): Observable<Tag> {
const getUrl: string = id === null ? `${url}` : `${url}/${id}`;
return this.http
.get<Tag>(`${url}/${id}`)
.pipe(catchError(this.log.handleError(serviceName, 'Get Tag'))) as Observable<Tag>;
.get<Tag>(getUrl)
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Tag>;
}
list(): Observable<Tag[]> {
return this.http
.get<Tag[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'List Tag'))) as Observable<Tag[]>;
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Tag[]>;
}
autocomplete(query: string): Observable<Tag[]> {
@ -37,10 +38,16 @@ export class TagService {
.pipe(catchError(this.log.handleError(serviceName, 'autocomplete'))) as Observable<Tag[]>;
}
delete(id: string): Observable<Tag> {
save(tag: Tag): Observable<Tag> {
return this.http
.delete<Tag>(`${url}/${id}`)
.pipe(catchError(this.log.handleError(serviceName, 'Delete Tag'))) as Observable<Tag>;
.post<Tag>(`${url}`, tag)
.pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable<Tag>;
}
update(tag: Tag): Observable<Tag> {
return this.http
.put<Tag>(`${url}/${tag.id}`, tag)
.pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable<Tag>;
}
saveOrUpdate(tag: Tag): Observable<Tag> {
@ -50,15 +57,15 @@ export class TagService {
return this.update(tag);
}
save(tag: Tag): Observable<Tag> {
delete(id: string): Observable<Tag> {
return this.http
.post<Tag>(`${url}`, tag)
.pipe(catchError(this.log.handleError(serviceName, 'Save Tag'))) as Observable<Tag>;
.delete<Tag>(`${url}/${id}`)
.pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable<Tag>;
}
update(tag: Tag): Observable<Tag> {
vouchers(tags: { name: string; enabled: boolean }[], voucherIds: string[]): Observable<boolean> {
return this.http
.put<Tag>(`${url}/${tag.id}`, tag)
.pipe(catchError(this.log.handleError(serviceName, 'Update Tag'))) as Observable<Tag>;
.post<boolean>(`${url}/vouchers`, { tags: tags, voucherIds: voucherIds })
.pipe(catchError(this.log.handleError(serviceName, 'Set Voucher Tags'))) as Observable<boolean>;
}
}