Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { MatTable, MatTableDataSource } from '@angular/material/table';
// NOTE: This component requires Angular 17+.
// input(), output(), effect(), computed() and ChangeDetectionStrategy.OnPush
// are Angular 17 features used here for signal-based reactivity and zoneless support.

import {
Component,
AfterViewInit,
ViewChild,
inject,
effect,
computed,
input,
output,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { Router } from '@angular/router';

import { SubSink } from 'subsink';
import { Observable, tap } from 'rxjs';

import { Budget, BudgetRecord } from '@app/model/finance/planning/budgets';

import { ShareBudgetModalComponent } from '../share-budget-modal/share-budget-modal.component';
Expand All @@ -18,50 +29,56 @@ import { ChildBudgetsModalComponent } from '../../modals/child-budgets-modal/chi
selector: 'app-budget-table',
templateUrl: './budget-table.component.html',
styleUrls: ['./budget-table.component.scss'],
// OnPush: Angular only checks this component when its signal inputs change.
// With provideExperimentalZonelessChangeDetection() in root providers this
// becomes a fully zoneless component — no Zone.js ticking required.
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BudgetTableComponent implements AfterViewInit {

export class BudgetTableComponent {

private _sbS = new SubSink();
// --- inject() replaces constructor parameters ---
private _router$$ = inject(Router);
private _dialog = inject(MatDialog);

@Input() budgets$: Observable<{overview: BudgetRecord[], budgets: any[]}>;
@Input() canPromote = false;
// --- Signal-based inputs replace @Input() + Observable pattern (Angular 17) ---
// The parent binds plain values; Angular wraps them into signals automatically.
// Child reads them with () — e.g. this.budgets()
budgets = input<{ overview: BudgetRecord[]; budgets: any[] }>();
canPromote = input<boolean>(false);

@Output() doPromote: EventEmitter<void> = new EventEmitter();

dataSource = new MatTableDataSource();
// --- Signal-based output replaces @Output() + EventEmitter (Angular 17) ---
doPromote = output<void>();

readonly dataSource = new MatTableDataSource<any>();
displayedColumns: string[] = ['name', 'status', 'startYear', 'duration', 'actions'];

@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild('sort', { static: true }) sort: MatSort;

overviewBudgets: BudgetRecord[] = [];

constructor(private _router$$: Router,
private _dialog: MatDialog,
) { }
// --- computed() replaces the plain overviewBudgets class field ---
// Recalculates automatically whenever the budgets input signal changes.
overviewBudgets = computed<BudgetRecord[]>(() => this.budgets()?.overview ?? []);

ngOnInit(): void {
this._sbS.sink = this.budgets$.pipe(tap((o) => {
this.overviewBudgets = o.overview;
this.dataSource.data = o.budgets;
})).subscribe();
constructor() {
// --- effect() replaces ngOnInit + SubSink subscription ---
// Runs reactively whenever the budgets input signal emits a new value.
// Keeps the imperative MatTableDataSource in sync with signal state.
effect(() => {
this.dataSource.data = this.budgets()?.budgets ?? [];
});
}

/**
* Checks whether the user has access to a certain feature.
*
* @TODO @IanOdhiambo9 - Please put proper access control architecture in place.
*/
access(requested:any)
{
/**
* Checks whether the user has access to a certain feature.
* @TODO @IanOdhiambo9 - Please put proper access control architecture in place.
*/
access(requested: any) {
switch (requested) {
case 'view':
case 'clone':
return true; //budget.access.owner || budget.access.view || budget.access.edit;
return true;
case 'edit':
return true; // (budget.access.owner || budget.access.edit) && budget.status !== BudgetStatus.InUse && budget.status !== BudgetStatus.InUse;
return true;
}
return false;
}
Expand All @@ -81,17 +98,18 @@ export class BudgetTableComponent {
}

promote() {
if (this.canPromote)
// Read signal value with () instead of plain property access
if (this.canPromote()) {
this.doPromote.emit();
}
}

/** Open share screen to configure budget access. */
openShareBudgetDialog(parent: Budget | false): void
{
openShareBudgetDialog(parent: Budget | false): void {
this._dialog.open(ShareBudgetModalComponent, {
panelClass: 'no-pad-dialog',
width: '600px',
data: parent != null ? parent : false
data: parent ?? false,
});
}

Expand All @@ -100,41 +118,39 @@ export class BudgetTableComponent {
this._dialog.open(CreateBudgetModalComponent, {
height: 'fit-content',
width: '600px',
data: parent != null ? parent : false
data: parent ?? false,
});
}

openChildBudgetDialog(parent : Budget): void
{
let children: any = this.overviewBudgets.find((budget) => budget.budget.id === parent.id)!?.children;
children = children?.map((child) => child.budget)
openChildBudgetDialog(parent: Budget): void {
// Read computed signal with () — replaces plain array access
let children: any = this.overviewBudgets().find(
(budget) => budget.budget.id === parent.id
)?.children;
children = children?.map((child: any) => child.budget);

this._dialog.open(ChildBudgetsModalComponent, {
height: 'fit-content',
minWidth: '600px',
data: {parent: parent, budgets: children}
data: { parent, budgets: children },
});
}

goToDetail(budgetId: string, action: string) {
this._router$$.navigate(['budgets', budgetId, action]).then(() => this._dialog.closeAll());
}

deleteBudget(budget: Budget) {

deleteBudget(_budget: Budget) {
// TODO: implement delete
}

translateStatus(status: number) {
switch (status) {
case 1:
return 'BUDGET.STATUS.ACTIVE';
case 0:
return 'BUDGET.STATUS.DESIGN';
case 9:
return 'BUDGET.STATUS.NO-USE';
case -1:
return 'BUDGET.STATUS.DELETED';
default:
return '';
case 1: return 'BUDGET.STATUS.ACTIVE';
case 0: return 'BUDGET.STATUS.DESIGN';
case 9: return 'BUDGET.STATUS.NO-USE';
case -1: return 'BUDGET.STATUS.DELETED';
default: return '';
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
</kujali-finance-search-header-card>
</kujali-finance-toolbar>

<div *ngIf="showFilter" class="filter-section">
<!-- <kujali-invoices-filter (filterChanged)='fieldsFilter($event)'>
</kujali-invoices-filter> -->
<div *ngIf="showFilter()" class="filter-section">
<!-- Future filter component -->
</div>

<div class="table-container">
<app-budget-table [budgets$]="allBudgets$"></app-budget-table>
<!-- Pass the full computed signal value — child reads .overview and .budgets internally -->
<app-budget-table [budgets]="allBudgets()"></app-budget-table>
</div>
</div>
</app-page>
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Component, OnInit, ViewChild } from '@angular/core';
// NOTE: This component requires Angular 17+.
// signal(), computed(), effect(), toSignal(), inject() and ChangeDetectionStrategy.OnPush
// are Angular 16/17 features.
// To enable fully zoneless change detection, add provideExperimentalZonelessChangeDetection()
// to the root providers in app.module.ts and remove zone.js from polyfills.

import { Component, inject, signal, computed, effect, ChangeDetectionStrategy } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';

import { cloneDeep as ___cloneDeep, flatMap as __flatMap } from 'lodash';
import { Observable, combineLatest, map, tap } from 'rxjs';

import { Logger } from '@iote/bricks-angular';

import { Budget, BudgetRecord, BudgetStatus, OrgBudgetsOverview } from '@app/model/finance/planning/budgets';

import { BudgetsStore, OrgBudgetsStore } from '@app/state/finance/budgetting/budgets';

import { CreateBudgetModalComponent } from '../../components/create-budget-modal/create-budget-modal.component';
Expand All @@ -16,93 +21,100 @@ import { CreateBudgetModalComponent } from '../../components/create-budget-modal
@Component({
selector: 'app-select-budget',
templateUrl: './select-budget.component.html',
styleUrls: ['./select-budget.component.scss',
'../../components/budget-view-styles.scss'],
styleUrls: [
'./select-budget.component.scss',
'../../components/budget-view-styles.scss'
],
// OnPush: Angular only re-checks this component when its signals change.
// Combined with provideExperimentalZonelessChangeDetection() in root providers
// this makes the component fully "zoneless" — no Zone.js ticking required.
changeDetection: ChangeDetectionStrategy.OnPush,
})
/** List of all active budgets on the system. */
export class SelectBudgetPageComponent implements OnInit
/** List of all active budgets on the system — Signals Refactor */
export class SelectBudgetPageComponent
{
/** Overview which contains all budgets of an organisation */
overview$!: Observable<OrgBudgetsOverview>;
sharedBudgets$: Observable<any[]>;

showFilter = false;

// budgetsLoaded: boolean = false;

allBudgets$: Observable<{overview: BudgetRecord[], budgets: any[]}>;

constructor(private _orgBudgets$$: OrgBudgetsStore,
private _budgets$$: BudgetsStore,
private _dialog: MatDialog,
private _logger: Logger)
{ }

ngOnInit() {
this.overview$ = this._orgBudgets$$.get();
this.sharedBudgets$ = this._budgets$$.get();

this.allBudgets$ = combineLatest([this.overview$, this._budgets$$.get()])
.pipe(map(([overview, budgets]) => {return {overview: __flatMap(overview), budgets: __flatMap(budgets)}}),
map((overview) => {
const trBudgets = overview.budgets.map((budget: any) => {budget['endYear'] = budget.startYear + budget.duration - 1; return budget;})
// this.budgetsLoaded = true;
return {overview: overview.overview, budgets: trBudgets}
}));
// --- inject() replaces constructor parameters ---
private _orgBudgets$$ = inject(OrgBudgetsStore);
private _budgets$$ = inject(BudgetsStore);
private _dialog = inject(MatDialog);
private _logger = inject(Logger);

// --- UI state as a signal instead of a plain boolean ---
showFilter = signal(false);

// --- Convert RxJS observables → signals via toSignal() ---
// toSignal subscribes automatically and keeps the signal in sync.
// initialValue is returned until the first observable emission.
private overviewRaw = toSignal(this._orgBudgets$$.get(), { initialValue: {} as OrgBudgetsOverview });
private budgetsRaw = toSignal(this._budgets$$.get(), { initialValue: [] });

// --- computed() replaces combineLatest + map pipeline ---
// Recalculates automatically whenever overviewRaw or budgetsRaw change.
allBudgets = computed(() => {
const overview = __flatMap(this.overviewRaw()) as BudgetRecord[];
const budgets = __flatMap(this.budgetsRaw());

const enriched = budgets.map((b: any) => ({
...b,
endYear: b.startYear + b.duration - 1,
}));

return { overview, budgets: enriched };
});

constructor() {
// --- effect() replaces imperative subscribe() side effects ---
// Runs reactively whenever allBudgets signal changes.
effect(() => {
const data = this.allBudgets();
this._logger.log(() => `[SelectBudgetPage] Budgets updated. Count: ${data.budgets.length}`);
});
}

applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
// this.dataSource.filter = filterValue.trim().toLowerCase();
const _filterValue = (event.target as HTMLInputElement).value;
// TODO: wire up table filter
}

fieldsFilter(value: (Invoice) => boolean) {
// this.filter$$.next(value);
fieldsFilter(_value: (invoice: any) => boolean) {
// TODO: wire up field-level filter
}

toogleFilter(value) {
// this.showFilter = value
toogleFilter(value: boolean) {
// .set() mutates the signal — triggers OnPush change detection automatically
this.showFilter.set(value);
}

openDialog(parent : Budget | false): void
{
openDialog(parent: Budget | false): void {
const dialog = this._dialog.open(CreateBudgetModalComponent, {
height: 'fit-content',
width: '600px',
data: parent != null ? parent : false
data: parent ?? false,
});

dialog.afterClosed().subscribe(() => {
// Dialog after action
})
});
}

/**
* @TODO - Review and fix
* Returns true if the budget can be activated */
/** @TODO - Review and fix. Returns true if the budget can be activated. */
canPromote(record: BudgetRecord) {
// Get's set on Budget Read from user privileges and budget status.
return (record.budget as any).canBeActivated;
}

/** Activate budget -> Promote to be used in */
setActive(record: BudgetRecord)
{
/** Activate budget — promote to be used in production. */
setActive(record: BudgetRecord) {
const toSave = ___cloneDeep(record.budget);

// Clean up budget record values.
delete (toSave as any).canBeActivated;
delete (toSave as any).access;

// Set Active
toSave.status = BudgetStatus.InUse;
(record as any).updating = true;

(<any> record).updating = true;
// Fire update
this._budgets$$.update(toSave)
.subscribe(() => {
(<any> record).updating = false;
this._logger.log(() => `Updated Budget with id ${toSave.id}. Set as an active budget for this org.`)
});
this._budgets$$.update(toSave).subscribe(() => {
(record as any).updating = false;
this._logger.log(() => `Budget ${toSave.id} set to Active.`);
});
}
}
}