diff --git a/projects/budgetkey/src/app/app-routing.module.ts b/projects/budgetkey/src/app/app-routing.module.ts
index 9fab5b6..823aa7c 100644
--- a/projects/budgetkey/src/app/app-routing.module.ts
+++ b/projects/budgetkey/src/app/app-routing.module.ts
@@ -9,6 +9,7 @@ const routes: Routes = [
{ path: 'about', loadChildren: () => import('./about/about.module').then(m => m.AboutModule) },
{ path: 'p', loadChildren: () => import('./profile/profile.module').then(m => m.ProfileModule) },
{ path: 'l', loadChildren: () => import('./list-page/list-page.module').then(m => m.ListPageModule) },
+ { path: 'dashboards', loadChildren: () => import('./dashboards/dashboards.module').then(m => m.DashboardsModule) },
{ path: 'not-found', component: PageNotFoundComponent },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
];
diff --git a/projects/budgetkey/src/app/dashboards/configurations/budget-transfers.yaml b/projects/budgetkey/src/app/dashboards/configurations/budget-transfers.yaml
new file mode 100644
index 0000000..0bb0615
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/configurations/budget-transfers.yaml
@@ -0,0 +1,102 @@
+title: דו״ח היסטוריה ונהנים לתכנית תקציבית
+doctype: budget
+placeholder: חיפוש תכנית תקציבית (למשל ״20.67.01״ או ״העברות לרשויות״)
+result_template: ":nice-code - :title"
+filters:
+ depth__gt: 0
+ year: 2025
+visualizations:
+- title: היסטוריה תקציבית
+ kind: table
+ query: >
+ select
+ year as "שנה:str",
+ net_allocated as "תקציב מקורי:fig",
+ net_revised as "תקציב מאושר:fig",
+ (net_revised / (net_allocated + 0.0001) - 1) * 100 as "שינוי באחוזים:fig",
+ net_executed as "ביצוע:fig",
+ (net_executed / (net_revised + 0.0001) ) * 100 as "אחוז ביצוע תקציב:fig"
+ from raw_budget where code=':code'
+ order by year desc
+- title: העברות בשנים האחרונות
+ kind: table
+ query: >
+ select
+ coalesce(to_char("date", 'YYYY/MM/DD'), 'טרם אושר') as "תאריך:str",
+ change_title as "סוג:str",
+ req_title as "כותרת:strw",
+ sum(net_expense_diff) as "נטו:fig",
+ sum(gross_expense_diff) as "ברוטו:fig",
+ sum(allocated_income_diff) as "הכנסה מיועדת:fig",
+ sum(commitment_limit_diff) as "הרשאה להתחייב:fig",
+ sum(personnel_max_diff) as "שיא כ״א:fig"
+ from raw_budget_changes where budget_code like ':code%%'
+ and year>=2015
+ group by 1,2,3,req_code,date
+ order by date desc
+- title: סיכום העברות בשנה האחרונה
+ kind: table
+ query: >
+ SELECT change_title as "סוג העברה:str",
+ sum(net_expense_diff) as "סך שינוי נטו:fig"
+ FROM raw_budget_changes
+ WHERE budget_code LIKE ':code%%'
+ AND YEAR=2024
+ AND NOT pending
+ GROUP BY 1
+- title: נתמכים עיקריים
+ kind: table
+ query: >
+ SELECT coalesce(entity_name, recipient) AS "מקבל התמיכה:str",
+ min(year_requested) as "משנת:str",
+ max(year_paid) as "עד שנת:str",
+ sum(amount_paid) as "סך שולם:fig",
+ sum(amount_total) as "סך אושר:fig"
+ FROM raw_supports
+ WHERE budget_code LIKE ':code%%'
+ GROUP BY 1
+ HAVING sum(amount_paid)>0
+ ORDER BY 4 DESC nulls LAST
+ LIMIT 20
+- title: נושאי תמיכה עיקריים
+ kind: table
+ query: >
+ SELECT support_title AS "נושא התמיכה:str",
+ min(year_requested) as "משנת:str",
+ max(year_paid) as "עד שנת:str",
+ sum(amount_paid) as "סך שולם:fig",
+ sum(amount_total) as "סך אושר:fig"
+ FROM raw_supports
+ WHERE budget_code LIKE ':code%%'
+ GROUP BY 1
+ HAVING sum(amount_paid)>0
+ ORDER BY 4 DESC nulls LAST
+ LIMIT 20
+- title: ספקים עיקריים
+ kind: table
+ query: >
+ SELECT coalesce(entity_name, supplier_name->>0) AS "ספק:str",
+ min(min_year) as "משנת:str",
+ max(max_year) as "עד שנת:str",
+ sum(executed) as "סך שולם:fig",
+ sum(volume) as "סך אושר:fig"
+ FROM contract_spending
+ WHERE budget_code LIKE ':code%%'
+ GROUP BY 1
+ ORDER BY 4 DESC nulls LAST
+ LIMIT 20
+- title: התקשרויות מרכזיות
+ kind: table
+ query: >
+ SELECT coalesce(entity_name, supplier_name->>0) AS "ספק:strw",
+ purpose as "מטרה:strw",
+ budget_title as "מתקציב:strw",
+ purchasing_unit as "המזמין:str",
+ purchase_method as "אופן רכישה:strw",
+ min_year as "משנת:str",
+ executed as "סך שולם עד כה:fig",
+ volume as "היקף ההתקשרות:fig"
+ FROM contract_spending
+ WHERE budget_code LIKE ':code%%'
+ ORDER BY 7 DESC nulls LAST
+ LIMIT 20
diff --git a/projects/budgetkey/src/app/dashboards/configurations/config.ts b/projects/budgetkey/src/app/dashboards/configurations/config.ts
new file mode 100644
index 0000000..3a6a5d2
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/configurations/config.ts
@@ -0,0 +1,49 @@
+export const config: any = {
+ "budget-transfers": {
+ "doctype": "budget",
+ "filters": {
+ "depth__gt": 0,
+ "year": 2025
+ },
+ "placeholder": "חיפוש תכנית תקציבית (למשל ״20.67.01״ או ״העברות לרשויות״)",
+ "result_template": ":nice-code - :title",
+ "title": "דו״ח היסטוריה ונהנים לתכנית תקציבית",
+ "visualizations": [
+ {
+ "kind": "table",
+ "query": "select year as \"שנה:str\", net_allocated as \"תקציב מקורי:fig\", net_revised as \"תקציב מאושר:fig\", (net_revised / (net_allocated + 0.0001) - 1) * 100 as \"שינוי באחוזים:fig\", net_executed as \"ביצוע:fig\", (net_executed / (net_revised + 0.0001) ) * 100 as \"אחוז ביצוע תקציב:fig\" from raw_budget where code=':code' order by year desc\n",
+ "title": "היסטוריה תקציבית"
+ },
+ {
+ "kind": "table",
+ "query": "select coalesce(to_char(\"date\", 'YYYY/MM/DD'), 'טרם אושר') as \"תאריך:str\", change_title as \"סוג:str\", req_title as \"כותרת:strw\", sum(net_expense_diff) as \"נטו:fig\", sum(gross_expense_diff) as \"ברוטו:fig\", sum(allocated_income_diff) as \"הכנסה מיועדת:fig\", sum(commitment_limit_diff) as \"הרשאה להתחייב:fig\", sum(personnel_max_diff) as \"שיא כ״א:fig\" from raw_budget_changes where budget_code like ':code%%' and year>=2015 group by 1,2,3,req_code,date order by date desc\n",
+ "title": "העברות בשנים האחרונות"
+ },
+ {
+ "kind": "table",
+ "query": "SELECT change_title as \"סוג העברה:str\",\n sum(net_expense_diff) as \"סך שינוי נטו:fig\"\nFROM raw_budget_changes WHERE budget_code LIKE ':code%%'\n AND YEAR=2024\n AND NOT pending\nGROUP BY 1\n",
+ "title": "סיכום העברות בשנה האחרונה"
+ },
+ {
+ "kind": "table",
+ "query": "SELECT coalesce(entity_name, recipient) AS \"מקבל התמיכה:str\",\n min(year_requested) as \"משנת:str\",\n max(year_paid) as \"עד שנת:str\",\n sum(amount_paid) as \"סך שולם:fig\",\n sum(amount_total) as \"סך אושר:fig\"\nFROM raw_supports WHERE budget_code LIKE ':code%%' GROUP BY 1 HAVING sum(amount_paid)>0 ORDER BY 4 DESC nulls LAST LIMIT 20\n",
+ "title": "נתמכים עיקריים"
+ },
+ {
+ "kind": "table",
+ "query": "SELECT support_title AS \"נושא התמיכה:str\",\n min(year_requested) as \"משנת:str\",\n max(year_paid) as \"עד שנת:str\",\n sum(amount_paid) as \"סך שולם:fig\",\n sum(amount_total) as \"סך אושר:fig\"\nFROM raw_supports WHERE budget_code LIKE ':code%%' GROUP BY 1 HAVING sum(amount_paid)>0 ORDER BY 4 DESC nulls LAST LIMIT 20\n",
+ "title": "נושאי תמיכה עיקריים"
+ },
+ {
+ "kind": "table",
+ "query": "SELECT coalesce(entity_name, supplier_name->>0) AS \"ספק:str\",\n min(min_year) as \"משנת:str\",\n max(max_year) as \"עד שנת:str\",\n sum(executed) as \"סך שולם:fig\",\n sum(volume) as \"סך אושר:fig\"\nFROM contract_spending WHERE budget_code LIKE ':code%%' GROUP BY 1 ORDER BY 4 DESC nulls LAST LIMIT 20\n",
+ "title": "ספקים עיקריים"
+ },
+ {
+ "kind": "table",
+ "query": "SELECT coalesce(entity_name, supplier_name->>0) AS \"ספק:strw\",\n purpose as \"מטרה:strw\",\n budget_title as \"מתקציב:strw\",\n purchasing_unit as \"המזמין:str\",\n purchase_method as \"אופן רכישה:strw\",\n min_year as \"משנת:str\",\n executed as \"סך שולם עד כה:fig\",\n volume as \"היקף ההתקשרות:fig\"\nFROM contract_spending WHERE budget_code LIKE ':code%%' ORDER BY 7 DESC nulls LAST LIMIT 20\n",
+ "title": "התקשרויות מרכזיות"
+ }
+ ]
+ }
+};
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/configurations/convert.py b/projects/budgetkey/src/app/dashboards/configurations/convert.py
new file mode 100755
index 0000000..813dd48
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/configurations/convert.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python
+import json
+import yaml
+import glob
+import pathlib
+
+ROOT = pathlib.Path(__file__).parent
+
+# for f in glob.glob('src/app/configurations/*yaml'):
+config = {}
+for f in ROOT.glob('*.yaml'):
+ print(f)
+ config[f.stem] = yaml.load(f.open(), Loader=yaml.SafeLoader)
+data = json.dumps(config, indent=2, ensure_ascii=False, sort_keys=True)
+with (ROOT / 'config.ts').open('w') as out:
+ out.write('export const config: any = %s;' % data)
+
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.html b/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.html
new file mode 100644
index 0000000..ed681ea
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.html
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.less b/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.less
new file mode 100644
index 0000000..4827598
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.less
@@ -0,0 +1,33 @@
+:host {
+ width: 100%;
+
+ input::placeholder {
+ color: #B9BCC3;
+ font-family: "Abraham TRIAL";
+ font-size: 16px;
+ line-height: 25px;
+ text-align: right;
+ }
+
+ input:focus::placeholder {
+ color: #B9BCC3;
+ font-family: "Abraham TRIAL";
+ font-size: 16px;
+ line-height: 25px;
+ text-align: right;
+ }
+
+ input {
+ appearance: none;
+ width: 100%;
+ background-color: #ffffff;
+
+ font-family: "Abraham TRIAL";
+ font-size: 16px;
+ line-height: 25px;
+ height: 44px;
+ border-radius: 25px;
+ box-shadow: none;
+ padding: 0 20px;
+ }
+}
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.ts b/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.ts
new file mode 100644
index 0000000..8ece603
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search-bar/dashboard-search-bar.component.ts
@@ -0,0 +1,19 @@
+import { Component } from '@angular/core';
+import { DashboardsApiService } from '../dashboards-api.service';
+
+@Component({
+ selector: 'app-dashboard-search-bar',
+ standalone: true,
+ imports: [],
+ templateUrl: './dashboard-search-bar.component.html',
+ styleUrl: './dashboard-search-bar.component.less'
+})
+export class DashboardSearchBarComponent {
+
+ constructor(public api: DashboardsApiService) { }
+
+ doSearch(event: Event) {
+ const el = event.target as HTMLInputElement;
+ this.api.search(el.value)
+ }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.html b/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.html
new file mode 100644
index 0000000..80d9cb9
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.html
@@ -0,0 +1,18 @@
+@if (selected == null) {
+
+ @for (result of api.searchResults(); track result.doc_id) {
+ -
+
+ }
+
+} @else {
+
+}
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.less b/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.less
new file mode 100644
index 0000000..4121eaf
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.less
@@ -0,0 +1,14 @@
+:host {
+ width: 100%;
+ padding: 0 20px;
+ max-width: 400px;
+ font-family: "Abraham TRIAL";
+
+ li {
+ cursor: pointer;
+
+ &.selected {
+ font-weight: 700;
+ }
+ }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.ts b/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.ts
new file mode 100644
index 0000000..022b74f
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search-results/dashboard-search-results.component.ts
@@ -0,0 +1,43 @@
+import { Component } from '@angular/core';
+import { DashboardsApiService } from '../dashboards-api.service';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'app-dashboard-search-results',
+ standalone: true,
+ imports: [],
+ templateUrl: './dashboard-search-results.component.html',
+ styleUrl: './dashboard-search-results.component.less'
+})
+export class DashboardSearchResultsComponent {
+ template: string;
+ title: string;
+
+ constructor(public api: DashboardsApiService, private router: Router) { }
+
+ ngOnInit() {
+ this.template = this.api.config.result_template;
+ this.title = this.api.config.title;
+ }
+
+ render(item: any) {
+ return this.template.replace(/:([a-z][-a-z0-9_.]*)/ig, (match, name) => {
+ return item[name];
+ });
+ }
+
+ set selected(item) {
+ console.log('config', this.api.baseRoute);
+ if (item) {
+ this.router.navigate([...this.api.baseRoute, item.doc_id.split('/').join('__')], {queryParamsHandling: 'preserve'});
+ } else {
+ this.router.navigate(this.api.baseRoute, {queryParamsHandling: 'preserve'});
+ }
+ }
+
+ get selected() {
+ return this.api.selectedItem();
+ }
+
+
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.html b/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.html
new file mode 100644
index 0000000..4b39e0f
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.html
@@ -0,0 +1,3 @@
+{{api.config.title}}
+
+
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.less b/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.less
new file mode 100644
index 0000000..3648b51
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.less
@@ -0,0 +1,7 @@
+:host {
+ width: 100%;
+ display: flex;
+ flex-flow: column;
+ align-content: flex-start;
+ gap: 2px;
+}
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.ts b/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.ts
new file mode 100644
index 0000000..d6f9ff1
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-search/dashboard-search.component.ts
@@ -0,0 +1,19 @@
+import { Component } from '@angular/core';
+import { DashboardSearchBarComponent } from '../dashboard-search-bar/dashboard-search-bar.component';
+import { DashboardSearchResultsComponent } from '../dashboard-search-results/dashboard-search-results.component';
+import { DashboardsApiService } from '../dashboards-api.service';
+
+@Component({
+ selector: 'app-dashboard-search',
+ standalone: true,
+ imports: [
+ DashboardSearchBarComponent,
+ DashboardSearchResultsComponent
+ ],
+ templateUrl: './dashboard-search.component.html',
+ styleUrl: './dashboard-search.component.less'
+})
+export class DashboardSearchComponent {
+
+ constructor(public api: DashboardsApiService) { }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.html b/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.html
new file mode 100644
index 0000000..d6b9db1
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.html
@@ -0,0 +1,15 @@
+
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.less b/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.less
new file mode 100644
index 0000000..adb670a
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.less
@@ -0,0 +1,3 @@
+a {
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.ts b/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.ts
new file mode 100644
index 0000000..de623ae
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-nav/dashboard-vis-nav.component.ts
@@ -0,0 +1,26 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { DashboardsApiService } from '../dashboards-api.service';
+
+@Component({
+ selector: 'app-dashboard-vis-nav',
+ standalone: true,
+ imports: [],
+ templateUrl: './dashboard-vis-nav.component.html',
+ styleUrl: './dashboard-vis-nav.component.less'
+})
+export class DashboardVisNavComponent {
+
+ @Input() selectVis: string;
+ @Input() loadingStatus: any;
+ @Output() selected = new EventEmitter();
+
+ constructor(public api: DashboardsApiService) { }
+
+ ngOnInit() {
+ }
+
+ selectedVis(title: string) {
+ this.selected.emit(title);
+ }
+
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.html b/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.html
new file mode 100644
index 0000000..fbf929e
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.html
@@ -0,0 +1,8 @@
+
+
+ @if (vis.kind === 'table') {
+
+
+ }
+
+
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.less b/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.less
new file mode 100644
index 0000000..e69de29
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.ts b/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.ts
new file mode 100644
index 0000000..6f09595
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-selector/dashboard-vis-selector.component.ts
@@ -0,0 +1,27 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { DashboardVisTableComponent } from '../dashboard-vis-table/dashboard-vis-table.component';
+
+@Component({
+ selector: 'app-dashboard-vis-selector',
+ standalone: true,
+ imports: [
+ DashboardVisTableComponent
+ ],
+ templateUrl: './dashboard-vis-selector.component.html',
+ styleUrl: './dashboard-vis-selector.component.less'
+})
+export class DashboardVisSelectorComponent {
+
+ @Input() vis: any;
+ @Input() visible: boolean;
+ @Output() loading = new EventEmitter();
+
+ constructor() { }
+
+ ngOnInit() {
+ }
+
+ reportLoading(loading: boolean) {
+ this.loading.emit(loading);
+ }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.html b/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.html
new file mode 100644
index 0000000..2a4c8d3
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.html
@@ -0,0 +1,29 @@
+@if (data) {
+ @if (data.length > 0) {
+
+
+
+ @for (f of fields; track f.title) {
+ {{f.title}} |
+ }
+
+
+
+ @for (row of data; track row) {
+
+ @for (f of fields; track f.field) {
+ |
+ }
+
+ }
+
+
+ } @else {
+ לא נמצאו נתונים
+ }
+} @else {
+
+
+ בטעינה...
+
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.less b/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.less
new file mode 100644
index 0000000..a8bba4c
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.less
@@ -0,0 +1,12 @@
+td {
+ white-space: nowrap;
+}
+
+::ng-deep .fig {
+ direction: ltr;
+ display: block;
+}
+
+::ng-deep .strw {
+ white-space: normal;
+}
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.ts b/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.ts
new file mode 100644
index 0000000..1c9a7d1
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-vis-table/dashboard-vis-table.component.ts
@@ -0,0 +1,65 @@
+import { Component, effect, EventEmitter, Input, Output } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { DashboardsApiService } from '../dashboards-api.service';
+
+@Component({
+ selector: 'app-dashboard-vis-table',
+ standalone: true,
+ imports: [],
+ templateUrl: './dashboard-vis-table.component.html',
+ styleUrl: './dashboard-vis-table.component.less'
+})
+export class DashboardVisTableComponent {
+
+ @Input() query: string;
+ @Output() loading = new EventEmitter();
+
+ data: any[] | null = null;
+ fields: any[];
+ request: Subscription;
+
+ constructor(private api: DashboardsApiService) {
+ effect(() => {
+ if (this.request) {
+ this.request.unsubscribe();
+ }
+ this.loading.emit(true);
+ this.data = null;
+ this.request = this.api.doQuery(this.query, this.api.selectedItem())
+ .subscribe((rows: any[]) => {
+ this.loading.emit(false);
+ this.data = rows;
+ });
+ });
+ }
+
+ str(x: any) {
+ return '' + x;
+ }
+
+ strw(x: any) {
+ return `${x}`;
+ }
+
+ fig(x: number) {
+ if (!x) {
+ return '—';
+ }
+ const digs = Math.abs(x) > 1000 ? 0 : 2;
+ const xstr = x.toLocaleString([], {minimumFractionDigits: digs,
+ maximumFractionDigits: digs});
+ return `${xstr}`;
+ }
+
+ ngOnInit() {
+ const aliases = RegExp('[Aa][Ss]\\s+"(([^":]+):([^"]+))"', 'g');
+ this.fields = [];
+ let match: any;
+ while ((match = aliases.exec(this.query)) != null) {
+ const field = match[1];
+ const title = match[2];
+ const formatter = (this as any)[match[3]];
+ this.fields.push({field, title, formatter});
+ }
+ }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.html b/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.html
new file mode 100644
index 0000000..36919da
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.html
@@ -0,0 +1,10 @@
+@if (api.selectedItem()) {
+
+ @for (vis of api.config.visualizations; track vis.title) {
+
+
+ }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.less b/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.less
new file mode 100644
index 0000000..e69de29
diff --git a/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.ts b/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.ts
new file mode 100644
index 0000000..399f33d
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboard-visualizations/dashboard-visualizations.component.ts
@@ -0,0 +1,24 @@
+import { Component, OnInit, signal } from '@angular/core';
+import { DashboardsApiService } from '../dashboards-api.service';
+import { DashboardVisNavComponent } from '../dashboard-vis-nav/dashboard-vis-nav.component';
+import { DashboardVisSelectorComponent } from '../dashboard-vis-selector/dashboard-vis-selector.component';
+
+@Component({
+ selector: 'app-dashboard-visualizations',
+ standalone: true,
+ imports: [
+ DashboardVisNavComponent,
+ DashboardVisSelectorComponent
+ ],
+ templateUrl: './dashboard-visualizations.component.html',
+ styleUrl: './dashboard-visualizations.component.less'
+})
+export class DashboardVisualizationsComponent {
+
+ selectedVis = signal('');
+ loadingStatus: any = {};
+
+ constructor(public api: DashboardsApiService) {
+ this.selectedVis.set(this.api.config.visualizations[0].title);
+ }
+}
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboards-api.service.ts b/projects/budgetkey/src/app/dashboards/dashboards-api.service.ts
new file mode 100644
index 0000000..2b29323
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboards-api.service.ts
@@ -0,0 +1,98 @@
+import { Injectable, signal } from '@angular/core';
+import { Subject, BehaviorSubject } from 'rxjs';
+import { switchMap, debounceTime, map, distinctUntilChanged } from 'rxjs/operators';
+import { HttpClient } from '@angular/common/http';
+import { ActivatedRoute, Router } from '@angular/router';
+
+
+@Injectable({
+ providedIn: 'root'
+})
+export class DashboardsApiService {
+ public config: any = {};
+ public baseRoute: string[] = [];
+
+ searchQueue = new Subject();
+ searchResults = signal([]);
+ selectedItem = signal(null);
+
+ constructor(private http: HttpClient, private router: Router) {
+ this.searchQueue
+ .pipe(
+ distinctUntilChanged((x, y) => x.term === y.term),
+ debounceTime(300),
+ switchMap((params) => this.doSearch(params))
+ ).subscribe((results: any) => {
+ this.searchResults.set(results);
+ });
+ }
+
+ search(term: string) {
+ const doctype = this.config.doctype;
+ const filters = this.config.filters;
+ console.log('term', term);
+ if (term) {
+ this.searchQueue.next({term, doctype, filters});
+ }
+ }
+
+ selectItem(doc_id: string | null) {
+ console.log('selectItem', doc_id);
+ if (doc_id) {
+ doc_id = doc_id?.split('__').join('/');
+ const url = `https://next.obudget.org/get/${doc_id}`;
+ this.http
+ .get(url)
+ .subscribe((result: any) => {
+ this.selectedItem.set(result.value);
+ });
+ } else {
+ this.selectedItem.set(null);
+ this.searchResults.set([]);
+ }
+ }
+
+
+ doSearch(params: any) {
+ const URL = 'https://next.obudget.org/search';
+ let url = `${URL}/${params.doctype}?q=${encodeURIComponent(params.term)}`;
+ const filters = JSON.stringify(params.filters).slice(1, -1);
+ url += '&filter=' + encodeURIComponent(filters);
+ return this.http
+ .get(url)
+ .pipe(
+ map((r: any) => {
+ const results: Array = r.search_results || [];
+ if (results.length === 1) {
+ this.router.navigate([...this.baseRoute, results[0].source.doc_id.split('/').join('__')], {queryParamsHandling: 'preserve'});
+ } else {
+ if (this.selectedItem()) {
+ this.selectedItem.set(null);
+ this.searchResults.set([]);
+ }
+ }
+ return results.map((x) => x.source);
+ })
+ );
+ }
+
+ getParameterPath(object: any, path: string, fallback: string): string {
+ return path?.split('.').reduce((o, i) => o?.[i], object) || fallback;
+ }
+
+ formatQuery(query: string, parameters: object): string {
+ return query.replace(/:([a-z][a-z0-9_.]*)/ig, (match, name) => {
+ return this.getParameterPath(parameters, name, match);
+ });
+ }
+
+ doQuery(query: string, item: any): any {
+ query = this.formatQuery(query, item);
+ const url = `https://next.obudget.org/api/query?query=${encodeURIComponent(query)}`;
+ return this.http
+ .get(url)
+ .pipe(
+ map((r: any) => r.rows)
+ );
+ }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.html b/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.html
new file mode 100644
index 0000000..9cb6c57
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.html
@@ -0,0 +1,6 @@
+
+
+
diff --git a/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.less b/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.less
new file mode 100644
index 0000000..8b7393d
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.less
@@ -0,0 +1,11 @@
+:host {
+ .inner {
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ overflow: hidden;
+ display: flex;
+ flex-flow: column;
+ align-content: stretch;
+ }
+}
\ No newline at end of file
diff --git a/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.ts b/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.ts
new file mode 100644
index 0000000..10961fe
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboards-page/dashboards-page.component.ts
@@ -0,0 +1,37 @@
+import { Component, OnInit } from '@angular/core';
+import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
+import { ListComponentsModule } from '../../list-components/list-components.module';
+import { config } from '../configurations/config';
+import { DashboardSearchComponent } from '../dashboard-search/dashboard-search.component';
+import { DashboardVisualizationsComponent } from '../dashboard-visualizations/dashboard-visualizations.component';
+import { DashboardsApiService } from '../dashboards-api.service';
+import { ActivatedRoute } from '@angular/router';
+
+@UntilDestroy()
+@Component({
+ selector: 'app-dashboards-page',
+ standalone: true,
+ imports: [
+ ListComponentsModule,
+ DashboardSearchComponent,
+ DashboardVisualizationsComponent
+ ],
+ templateUrl: './dashboards-page.component.html',
+ styleUrls: ['./dashboards-page.component.less']
+})
+export class DashboardsPageComponent {
+
+ constructor(private api: DashboardsApiService, private route: ActivatedRoute) {
+ this.route.params.pipe(
+ untilDestroyed(this)
+ ).subscribe(params => {
+ this.api.config = config[params['config']];
+ this.api.baseRoute = ['/dashboards', params['config']];
+ if (params['item-id']) {
+ this.api.selectItem(params['item-id']);
+ } else {
+ this.api.selectItem(null);
+ }
+ });
+ }
+}
diff --git a/projects/budgetkey/src/app/dashboards/dashboards-routing.module.ts b/projects/budgetkey/src/app/dashboards/dashboards-routing.module.ts
new file mode 100644
index 0000000..94678a8
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboards-routing.module.ts
@@ -0,0 +1,20 @@
+import { NgModule } from "@angular/core";
+import { Routes, RouterModule } from "@angular/router";
+import { DashboardsPageComponent } from "./dashboards-page/dashboards-page.component";
+
+const routes: Routes = [
+ {
+ path: ':config/:item-id',
+ component: DashboardsPageComponent
+ },
+ {
+ path: ':config',
+ component: DashboardsPageComponent
+ },
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class DashboardsRoutingModule {}
diff --git a/projects/budgetkey/src/app/dashboards/dashboards.module.ts b/projects/budgetkey/src/app/dashboards/dashboards.module.ts
new file mode 100644
index 0000000..b953d58
--- /dev/null
+++ b/projects/budgetkey/src/app/dashboards/dashboards.module.ts
@@ -0,0 +1,20 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { CommonComponentsModule } from '../common-components/common-components.module';
+import { DashboardsPageComponent } from './dashboards-page/dashboards-page.component';
+import { DashboardsRoutingModule } from './dashboards-routing.module';
+import { ListComponentsModule } from '../list-components/list-components.module';
+
+
+
+@NgModule({
+ declarations: [
+ ],
+ imports: [
+ CommonModule,
+ CommonComponentsModule,
+ ListComponentsModule,
+ DashboardsRoutingModule,
+ ]
+})
+export class DashboardsModule { }