diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 00000000..5a587c63 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,305 @@ +# Quick Start Guide - Run Kujali Locally + +## About Kujali + +Kujali is a cashflow management tool for start-ups and scale-ups. A core feature allows users to define budgets and then manage revenue realization through CRM, sales forecasting, invoicing, etc. + +Kujali is built as a **monorepo** deployed to Angular, Firebase, and Google Cloud. For data storage, Kujali uses **Firestore** with readers that stream data to **PostgreSQL** and **BigQuery** for BI & analysis. + +### Monorepo Structure + +The monorepo follows a clear structure ([monorepo.tools](https://monorepo.tools/)): + +``` +/apps # Lightweight containers that roll up features into deployable applications + /kujali # Main Angular frontend application + /kujali-functions # Firebase Cloud Functions backend + +/libs # The "body" of the app - all core logic and features + /elements # Reusable UI components (buttons, forms, etc.) + /features # Actual logical frontend features (budgeting, finance, CRM, etc.) + /state # Link between frontend and backend (state management) + /model # Cross-application data models + /util # Reusable libraries that can be used across different client repos + /functions # Backend Cloud Functions organized by domain +``` + +**Key points:** +- **Apps**: Lightweight containers that configure cross-application behavior (not feature logic) +- **Libs**: Contains all the features and logic + - `elements/`: Reusable components + - `features/`: Logical frontend features + - `state/`: Bridge between front and back + - `model/`: Cross-application models + - `util/`: Reusable libraries + +### Data Architecture + +- **Production**: Firestore (primary storage) → Readers stream data → PostgreSQL/BigQuery (for BI & analysis) +- **Development**: Direct PostgreSQL connection for simplified local setup + +## Prerequisites Check +- ✅ Node.js installed (you have v24.11.1) +- ⚠️ Note: README suggests Node ^14.20.1, but v24 should work +- PostgreSQL server (local) +- Dependencies need to be installed + +## Step-by-Step Setup + +### 1. Install PostgreSQL + +**Linux (Arch/Ubuntu/Debian):** +```bash +# Arch Linux +sudo pacman -S postgresql + +# Ubuntu/Debian +sudo apt update +sudo apt install postgresql postgresql-contrib +``` + +**macOS:** +```bash +brew install postgresql@14 +brew services start postgresql@14 +``` + +**Windows:** +Download from https://www.postgresql.org/download/windows/ and run the installer + +### 2. Setup PostgreSQL Database + +**Start PostgreSQL service:** +```bash +# Linux (systemd) +sudo systemctl start postgresql +sudo systemctl enable postgresql + +# macOS (Homebrew) +brew services start postgresql@14 + +# Windows: Service starts automatically after installation +``` + +**Create database and user:** +```bash +# Switch to postgres user +sudo -u postgres psql + +# Or on macOS/Windows, connect directly: +psql postgres +``` + +**In PostgreSQL shell, run:** +```sql +-- Create database +CREATE DATABASE kujali_dev; + +-- Create user +CREATE USER kujali_user WITH PASSWORD 'your_secure_password'; + +-- Grant privileges +GRANT ALL PRIVILEGES ON DATABASE kujali_dev TO kujali_user; + +-- Connect to the database +\c kujali_dev + +-- Grant schema privileges (if needed) +GRANT ALL ON SCHEMA public TO kujali_user; + +-- Exit +\q +``` + +### 3. Install Dependencies +```bash +npm install --legacy-peer-deps +``` + +### 4. Configure Environment + +Update the environment file with your PostgreSQL configuration: +- File: `apps/kujali/src/environments/environment.ts` + +**Note:** In development, we use PostgreSQL directly instead of Firestore. In production, the app uses Firestore with data streams to PostgreSQL/BigQuery for BI. + +**Update environment.ts:** +```typescript +export const environment = { + production: false, + useEmulators: false, // Set to false when using local PostgreSQL + + // Development: Local PostgreSQL configuration + database: { + type: 'postgres', + host: 'localhost', + port: 5432, + username: 'kujali_user', + password: 'your_secure_password', + database: 'kujali_dev', + synchronize: false, // Set to false in production + logging: true + }, + + // Firebase config (may still be needed for auth, even in dev) + firebase: { + apiKey: "your-api-key", + authDomain: "your-project.firebaseapp.com", + projectId: "your-project-id", + storageBucket: "your-project.appspot.com", + messagingSenderId: "123456789", + appId: "1:123456789:web:abc123" + }, + + project: { + name: 'kujali-dev' + } +} +``` + +### 5. Run Database Migrations (if applicable) + +If your project has database migrations: +```bash +# Example migration commands (adjust based on your migration tool) +npm run migration:run +# or +npm run db:migrate +``` + +### 6. Seed Database (Optional) + +If you have seed data: +```bash +npm run db:seed +``` + +### 7. Start PostgreSQL (if not already running) + +**Check PostgreSQL status:** +```bash +# Linux +sudo systemctl status postgresql + +# macOS +brew services list | grep postgresql + +# Connect test +psql -U kujali_user -d kujali_dev -h localhost +``` + +### 8. Run the Application + +**Start Angular development server:** +```bash +npm start +``` + +The app will be available at: +- **App**: http://localhost:4200 + +**If you have a separate backend API server:** +```bash +# Terminal 1: Start backend API (if applicable) +npm run start:backend +# or +cd backend && npm start + +# Terminal 2: Start Angular app +npm start +``` + +### 9. Access the App +- **App**: http://localhost:4200 + +**Note on Architecture:** +- The frontend (`apps/kujali`) connects directly to your local PostgreSQL +- In production, the app uses Firestore as primary storage +- Cloud Functions (`apps/kujali-functions`) handle backend logic in production + +**If you need to run Cloud Functions locally (for full production-like setup):** +```bash +# Terminal 1: Start Firebase emulators (if you want to test functions) +npm run start-firebase-emulators + +# Terminal 2: Start Angular app +npm start +``` + +**For simple development with just PostgreSQL:** +```bash +# Just start the Angular app - it will use your local PostgreSQL +npm start +``` + +### 10. Demo Login Credentials +``` +Email: user@demo.com +Password: demoUser +``` + +**Note:** You may need to create this user in your PostgreSQL database if using a custom auth system. + +## Troubleshooting + +**If PostgreSQL connection fails:** +- Verify PostgreSQL is running: `sudo systemctl status postgresql` (Linux) or `brew services list` (macOS) +- Check connection: `psql -U kujali_user -d kujali_dev -h localhost` +- Verify credentials in `environment.ts` match your PostgreSQL setup +- Check PostgreSQL logs: `/var/log/postgresql/` (Linux) or check Homebrew logs (macOS) +- Ensure PostgreSQL is listening on localhost: check `postgresql.conf` (listen_addresses = 'localhost') + +**If database errors occur:** +- Verify database exists: `psql -U postgres -c "\l" | grep kujali_dev` +- Check user permissions: `psql -U postgres -d kujali_dev -c "\du"` +- Run migrations if you have them: `npm run migration:run` + +**If npm install fails:** +- Use `--legacy-peer-deps` flag: `npm install --legacy-peer-deps` +- Try with Node 14 or 16 if v24 has issues: `nvm use 16` (if using nvm) +- Clear cache: `npm cache clean --force` + +**If build fails:** +- Check environment.ts has valid database configuration +- Ensure all dependencies installed: `npm install --legacy-peer-deps` +- Verify database connection before building + +**If port conflicts occur:** +- PostgreSQL default port: 5432 +- Angular dev server: 4200 +- Backend API (if separate): Usually 3000 +- Check what's using ports: `lsof -i :5432` (macOS/Linux) or `netstat -ano | findstr :5432` (Windows) + +## PostgreSQL Quick Reference + +**Connection strings:** +- Local: `postgresql://kujali_user:password@localhost:5432/kujali_dev` +- Connection test: `psql -U kujali_user -d kujali_dev -h localhost` + +**Common PostgreSQL commands:** +```sql +-- List databases +\l + +-- Connect to database +\c kujali_dev + +-- List tables +\dt + +-- Describe table +\d table_name + +-- Exit +\q +``` + +## Quick Commands Reference +- `npm install --legacy-peer-deps` - Install dependencies +- `npm start` - Start Angular dev server +- `npm run build` - Build for production +- `sudo systemctl start postgresql` - Start PostgreSQL (Linux) +- `brew services start postgresql@14` - Start PostgreSQL (macOS) +- `psql -U kujali_user -d kujali_dev` - Connect to database + + diff --git a/angular.json b/angular.json index 35e262c4..a7976c6d 100644 --- a/angular.json +++ b/angular.json @@ -21,6 +21,7 @@ "@app/model/finance/planning/budget-lines/by-year": "libs/model/finance/planning/budget-lines-by-year", "@app/model/finance/planning/budgets": "libs/model/finance/planning/budgets", "@app/model/finance/planning/time": "libs/model/finance/planning/time", + "@app/model/budgetting/notes": "libs/model/budgetting/notes/budget-notes", "@app/model/organisation": "libs/model/organisation/main", "@app/state/finance/budgetting/budgets": "libs/state/finance/budgetting/budgets", "@app/state/finance/budgetting/rendering": "libs/state/finance/budgetting/rendering", @@ -93,6 +94,7 @@ "model-finance-planning-budget-grouping": "libs/model/finance/planning/budget-grouping", "model-finance-planning-budget-rendering": "libs/model/finance/planning/budget-rendering", "model-finance-planning-budget-rendering-state": "libs/model/finance/planning/budget-rendering-state", + "model-budgetting-notes-budget-notes": "libs/model/budgetting/notes/budget-notes", "model-roles-base": "libs/model/roles/base", "model-tags-base": "libs/model/tags/base", "private-state-organisation": "libs/private/state/organisation", diff --git a/kujali/.editorconfig b/kujali/.editorconfig new file mode 100644 index 00000000..6e87a003 --- /dev/null +++ b/kujali/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/kujali/.github/workflows/ci.yml b/kujali/.github/workflows/ci.yml new file mode 100644 index 00000000..98b4ae49 --- /dev/null +++ b/kujali/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + actions: read + contents: read + +jobs: + main: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: tree:0 + fetch-depth: 0 + + # This enables task distribution via Nx Cloud + # Run this command as early as possible, before dependencies are installed + # Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun + # Uncomment this line to enable task distribution + # - run: npx nx start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci" + + # Cache node_modules + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - run: npm ci + - run: npx playwright install --with-deps + + # Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud + # - run: npx nx-cloud record -- echo Hello World + # When you enable task distribution, run the e2e-ci task instead of e2e + - run: npx nx run-many -t lint test build e2e + # Nx Cloud recommends fixes for failures to help you get CI green faster. Learn more: https://nx.dev/ci/features/self-healing-ci + - run: npx nx fix-ci + if: always() diff --git a/kujali/.gitignore b/kujali/.gitignore new file mode 100644 index 00000000..9c2ca46d --- /dev/null +++ b/kujali/.gitignore @@ -0,0 +1,46 @@ +# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# compiled output +dist +tmp +out-tsc + +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.nx/cache +.nx/workspace-data +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md + +.angular diff --git a/kujali/.prettierignore b/kujali/.prettierignore new file mode 100644 index 00000000..113709c9 --- /dev/null +++ b/kujali/.prettierignore @@ -0,0 +1,6 @@ +# Add files here to ignore them from prettier formatting +/dist +/coverage +/.nx/cache +/.nx/workspace-data +.angular diff --git a/kujali/.prettierrc b/kujali/.prettierrc new file mode 100644 index 00000000..544138be --- /dev/null +++ b/kujali/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/kujali/.vscode/extensions.json b/kujali/.vscode/extensions.json new file mode 100644 index 00000000..db03eda7 --- /dev/null +++ b/kujali/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "nrwl.angular-console", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "firsttris.vscode-jest-runner", + "ms-playwright.playwright" + ] +} diff --git a/kujali/README.md b/kujali/README.md new file mode 100644 index 00000000..746cce7f --- /dev/null +++ b/kujali/README.md @@ -0,0 +1,82 @@ +# Kujali + + + +✨ Your new, shiny [Nx workspace](https://nx.dev) is almost ready ✨. + +[Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/angular-monorepo-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed! + +## Finish your CI setup + +[Click here to finish setting up your workspace!](https://cloud.nx.app/connect/vAD1p9EKUr) + + +## Run tasks + +To run the dev server for your app, use: + +```sh +npx nx serve kujali +``` + +To create a production bundle: + +```sh +npx nx build kujali +``` + +To see all available targets to run for a project, run: + +```sh +npx nx show project kujali +``` + +These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files. + +[More about running tasks in the docs »](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +## Add new projects + +While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature. + +Use the plugin's generator to create new projects. + +To generate a new application, use: + +```sh +npx nx g @nx/angular:app demo +``` + +To generate a new library, use: + +```sh +npx nx g @nx/angular:lib mylib +``` + +You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list ` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE. + +[Learn more about Nx plugins »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + + +[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +## Install Nx Console + +Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ. + +[Install Nx Console »](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +## Useful links + +Learn more: + +- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/angular-monorepo-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +And join the Nx community: +- [Discord](https://go.nx.dev/community) +- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl) +- [Our Youtube channel](https://www.youtube.com/@nxdevtools) +- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) diff --git a/kujali/apps/kujali-e2e/eslint.config.mjs b/kujali/apps/kujali-e2e/eslint.config.mjs new file mode 100644 index 00000000..b2e9fac0 --- /dev/null +++ b/kujali/apps/kujali-e2e/eslint.config.mjs @@ -0,0 +1,12 @@ +import playwright from 'eslint-plugin-playwright'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + playwright.configs['flat/recommended'], + ...baseConfig, + { + files: ['**/*.ts', '**/*.js'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/kujali/apps/kujali-e2e/playwright.config.ts b/kujali/apps/kujali-e2e/playwright.config.ts new file mode 100644 index 00000000..58e4ed41 --- /dev/null +++ b/kujali/apps/kujali-e2e/playwright.config.ts @@ -0,0 +1,68 @@ +import { defineConfig, devices } from '@playwright/test'; +import { nxE2EPreset } from '@nx/playwright/preset'; +import { workspaceRoot } from '@nx/devkit'; + +// For CI, you may want to set BASE_URL to the deployed application. +const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...nxE2EPreset(__filename, { testDir: './src' }), + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npx nx run kujali:serve', + url: 'http://localhost:4200', + reuseExistingServer: true, + cwd: workspaceRoot, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + // Uncomment for mobile browsers support + /* { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, */ + + // Uncomment for branded browsers + /* { + name: 'Microsoft Edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + { + name: 'Google Chrome', + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + } */ + ], +}); diff --git a/kujali/apps/kujali-e2e/project.json b/kujali/apps/kujali-e2e/project.json new file mode 100644 index 00000000..fbda4e24 --- /dev/null +++ b/kujali/apps/kujali-e2e/project.json @@ -0,0 +1,9 @@ +{ + "name": "kujali-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/kujali-e2e/src", + "implicitDependencies": ["kujali"], + "// targets": "to see all targets run: nx show project kujali-e2e --web", + "targets": {} +} diff --git a/kujali/apps/kujali-e2e/src/example.spec.ts b/kujali/apps/kujali-e2e/src/example.spec.ts new file mode 100644 index 00000000..fa8f1f33 --- /dev/null +++ b/kujali/apps/kujali-e2e/src/example.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('/'); + + // Expect h1 to contain a substring. + expect(await page.locator('h1').innerText()).toContain('Welcome'); +}); diff --git a/kujali/apps/kujali-e2e/tsconfig.json b/kujali/apps/kujali-e2e/tsconfig.json new file mode 100644 index 00000000..0b670c67 --- /dev/null +++ b/kujali/apps/kujali-e2e/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "outDir": "../../dist/out-tsc", + "sourceMap": false, + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "**/*.ts", + "**/*.js", + "playwright.config.ts", + "src/**/*.spec.ts", + "src/**/*.spec.js", + "src/**/*.test.ts", + "src/**/*.test.js", + "src/**/*.d.ts" + ] +} diff --git a/kujali/apps/kujali/eslint.config.mjs b/kujali/apps/kujali/eslint.config.mjs new file mode 100644 index 00000000..9b3aa715 --- /dev/null +++ b/kujali/apps/kujali/eslint.config.mjs @@ -0,0 +1,34 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'app', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/kujali/apps/kujali/jest.config.ts b/kujali/apps/kujali/jest.config.ts new file mode 100644 index 00000000..e47b8bf8 --- /dev/null +++ b/kujali/apps/kujali/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'kujali', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/apps/kujali', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/kujali/apps/kujali/project.json b/kujali/apps/kujali/project.json new file mode 100644 index 00000000..60836e0b --- /dev/null +++ b/kujali/apps/kujali/project.json @@ -0,0 +1,90 @@ +{ + "name": "kujali", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "app", + "sourceRoot": "apps/kujali/src", + "tags": [], + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/kujali", + "browser": "apps/kujali/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/kujali/tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "apps/kujali/public" + } + ], + "styles": ["apps/kujali/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kb", + "maximumError": "8kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "kujali:build:production" + }, + "development": { + "buildTarget": "kujali:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular/build:extract-i18n", + "options": { + "buildTarget": "kujali:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/kujali/jest.config.ts", + "tsConfig": "apps/kujali/tsconfig.spec.json" + } + }, + "serve-static": { + "continuous": true, + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "kujali:build", + "port": 4200, + "staticFilePath": "dist/apps/kujali/browser", + "spa": true + } + } + } +} diff --git a/kujali/apps/kujali/src/app/app.config.ts b/kujali/apps/kujali/src/app/app.config.ts new file mode 100644 index 00000000..d3dcb8ea --- /dev/null +++ b/kujali/apps/kujali/src/app/app.config.ts @@ -0,0 +1,15 @@ +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideZoneChangeDetection, +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { appRoutes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(appRoutes), + ], +}; diff --git a/kujali/apps/kujali/src/app/app.css b/kujali/apps/kujali/src/app/app.css new file mode 100644 index 00000000..e69de29b diff --git a/kujali/apps/kujali/src/app/app.html b/kujali/apps/kujali/src/app/app.html new file mode 100644 index 00000000..2339c6ac --- /dev/null +++ b/kujali/apps/kujali/src/app/app.html @@ -0,0 +1,2 @@ + + diff --git a/kujali/apps/kujali/src/app/app.routes.ts b/kujali/apps/kujali/src/app/app.routes.ts new file mode 100644 index 00000000..8762dfe2 --- /dev/null +++ b/kujali/apps/kujali/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Route } from '@angular/router'; + +export const appRoutes: Route[] = []; diff --git a/kujali/apps/kujali/src/app/app.spec.ts b/kujali/apps/kujali/src/app/app.spec.ts new file mode 100644 index 00000000..85f4e725 --- /dev/null +++ b/kujali/apps/kujali/src/app/app.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; +import { NxWelcome } from './nx-welcome'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App, NxWelcome], + }).compileComponents(); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain( + 'Welcome kujali' + ); + }); +}); diff --git a/kujali/apps/kujali/src/app/app.ts b/kujali/apps/kujali/src/app/app.ts new file mode 100644 index 00000000..b434da5f --- /dev/null +++ b/kujali/apps/kujali/src/app/app.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { NxWelcome } from './nx-welcome'; + +@Component({ + imports: [NxWelcome, RouterModule], + selector: 'app-root', + templateUrl: './app.html', + styleUrl: './app.css', +}) +export class App { + protected title = 'kujali'; +} diff --git a/kujali/apps/kujali/src/app/nx-welcome.ts b/kujali/apps/kujali/src/app/nx-welcome.ts new file mode 100644 index 00000000..5b1ef921 --- /dev/null +++ b/kujali/apps/kujali/src/app/nx-welcome.ts @@ -0,0 +1,869 @@ +import { Component, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-nx-welcome', + imports: [CommonModule], + template: ` + + +
+
+ +
+

+ Hello there, + Welcome kujali 👋 +

+
+ +
+
+

+ + + + You're up and running +

+ What's next? +
+
+ + + +
+
+ + + +
+

Next steps

+

Here are some things you can do with Nx:

+
+ + + + + Build, test and lint your app + +
# Build
+nx build 
+# Test
+nx test 
+# Lint
+nx lint 
+# Run them together!
+nx run-many -t build test lint
+
+
+ + + + + View project details + +
nx show project kujali
+
+ +
+ + + + + View interactive project graph + +
nx graph
+
+ +
+ + + + + Add UI library + +
# Generate UI lib
+nx g @nx/angular:lib ui
+# Add a component
+nx g @nx/angular:component ui/src/lib/button
+
+
+

+ Carefully crafted with + + + +

+
+
+ `, + styles: [], + encapsulation: ViewEncapsulation.None, +}) +export class NxWelcome {} diff --git a/kujali/apps/kujali/src/index.html b/kujali/apps/kujali/src/index.html new file mode 100644 index 00000000..bae1367d --- /dev/null +++ b/kujali/apps/kujali/src/index.html @@ -0,0 +1,13 @@ + + + + + kujali + + + + + + + + diff --git a/kujali/apps/kujali/src/main.ts b/kujali/apps/kujali/src/main.ts new file mode 100644 index 00000000..190f3418 --- /dev/null +++ b/kujali/apps/kujali/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/kujali/apps/kujali/src/styles.css b/kujali/apps/kujali/src/styles.css new file mode 100644 index 00000000..90d4ee00 --- /dev/null +++ b/kujali/apps/kujali/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/kujali/apps/kujali/src/test-setup.ts b/kujali/apps/kujali/src/test-setup.ts new file mode 100644 index 00000000..ea414013 --- /dev/null +++ b/kujali/apps/kujali/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/kujali/apps/kujali/tsconfig.app.json b/kujali/apps/kujali/tsconfig.app.json new file mode 100644 index 00000000..46b15b0b --- /dev/null +++ b/kujali/apps/kujali/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "jest.config.ts", + "src/test-setup.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "jest.config.cts" + ] +} diff --git a/kujali/apps/kujali/tsconfig.json b/kujali/apps/kujali/tsconfig.json new file mode 100644 index 00000000..c614c97b --- /dev/null +++ b/kujali/apps/kujali/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "target": "es2022", + "moduleResolution": "bundler", + "isolatedModules": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/kujali/apps/kujali/tsconfig.spec.json b/kujali/apps/kujali/tsconfig.spec.json new file mode 100644 index 00000000..2eb8772e --- /dev/null +++ b/kujali/apps/kujali/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/kujali/eslint.config.mjs b/kujali/eslint.config.mjs new file mode 100644 index 00000000..5f08df3b --- /dev/null +++ b/kujali/eslint.config.mjs @@ -0,0 +1,42 @@ +import nx from '@nx/eslint-plugin'; + +export default [ + ...nx.configs['flat/base'], + ...nx.configs['flat/typescript'], + ...nx.configs['flat/javascript'], + { + ignores: ['**/dist', '**/out-tsc'], + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + }, + }, + { + files: [ + '**/*.ts', + '**/*.tsx', + '**/*.cts', + '**/*.mts', + '**/*.js', + '**/*.jsx', + '**/*.cjs', + '**/*.mjs', + ], + // Override or add rules here + rules: {}, + }, +]; diff --git a/kujali/jest.config.ts b/kujali/jest.config.ts new file mode 100644 index 00000000..c49c9a9d --- /dev/null +++ b/kujali/jest.config.ts @@ -0,0 +1,6 @@ +import type { Config } from 'jest'; +import { getJestProjectsAsync } from '@nx/jest'; + +export default async (): Promise => ({ + projects: await getJestProjectsAsync(), +}); diff --git a/kujali/jest.preset.js b/kujali/jest.preset.js new file mode 100644 index 00000000..f078ddce --- /dev/null +++ b/kujali/jest.preset.js @@ -0,0 +1,3 @@ +const nxPreset = require('@nx/jest/preset').default; + +module.exports = { ...nxPreset }; diff --git a/kujali/nx.json b/kujali/nx.json new file mode 100644 index 00000000..fd759565 --- /dev/null +++ b/kujali/nx.json @@ -0,0 +1,69 @@ +{ + "$schema": "./node_modules/nx/schemas/nx-schema.json", + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "production": [ + "default", + "!{projectRoot}/.eslintrc.json", + "!{projectRoot}/eslint.config.mjs", + "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", + "!{projectRoot}/tsconfig.spec.json", + "!{projectRoot}/jest.config.[jt]s", + "!{projectRoot}/src/test-setup.[jt]s", + "!{projectRoot}/test-setup.[jt]s" + ], + "sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"] + }, + "nxCloudId": "692b19e39fdb553d8639e0a6", + "targetDefaults": { + "@angular/build:application": { + "cache": true, + "dependsOn": ["^build"], + "inputs": ["production", "^production"] + }, + "@nx/eslint:lint": { + "cache": true, + "inputs": [ + "default", + "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/.eslintignore", + "{workspaceRoot}/eslint.config.mjs" + ] + }, + "@nx/jest:jest": { + "cache": true, + "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], + "options": { + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "plugins": [ + { + "plugin": "@nx/playwright/plugin", + "options": { + "targetName": "e2e" + } + }, + { + "plugin": "@nx/eslint/plugin", + "options": { + "targetName": "lint" + } + } + ], + "generators": { + "@nx/angular:application": { + "e2eTestRunner": "playwright", + "linter": "eslint", + "style": "css", + "unitTestRunner": "jest" + } + } +} diff --git a/kujali/package.json b/kujali/package.json new file mode 100644 index 00000000..8c7ac53c --- /dev/null +++ b/kujali/package.json @@ -0,0 +1,59 @@ +{ + "name": "@kujali/source", + "version": "0.0.0", + "license": "MIT", + "scripts": {}, + "private": true, + "dependencies": { + "@angular/common": "~20.3.0", + "@angular/compiler": "~20.3.0", + "@angular/core": "~20.3.0", + "@angular/forms": "~20.3.0", + "@angular/platform-browser": "~20.3.0", + "@angular/platform-browser-dynamic": "~20.3.0", + "@angular/router": "~20.3.0", + "rxjs": "~7.8.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/core": "~20.3.0", + "@angular-devkit/schematics": "~20.3.0", + "@angular/build": "~20.3.0", + "@angular/cli": "~20.3.0", + "@angular/compiler-cli": "~20.3.0", + "@angular/language-service": "~20.3.0", + "@eslint/js": "^9.8.0", + "@nx/angular": "22.1.3", + "@nx/devkit": "22.1.3", + "@nx/eslint": "22.1.3", + "@nx/eslint-plugin": "22.1.3", + "@nx/jest": "22.1.3", + "@nx/js": "22.1.3", + "@nx/playwright": "22.1.3", + "@nx/web": "22.1.3", + "@nx/workspace": "22.1.3", + "@playwright/test": "^1.36.0", + "@schematics/angular": "~20.3.0", + "@swc-node/register": "~1.9.1", + "@swc/core": "~1.5.7", + "@swc/helpers": "~0.5.11", + "@types/jest": "^29.5.12", + "@types/node": "18.16.9", + "@typescript-eslint/utils": "^8.40.0", + "angular-eslint": "^20.3.0", + "eslint": "^9.8.0", + "eslint-config-prettier": "^10.0.0", + "eslint-plugin-playwright": "^1.6.2", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-preset-angular": "~14.6.1", + "jest-util": "^29.7.0", + "nx": "22.1.3", + "prettier": "^2.6.2", + "ts-jest": "^29.1.0", + "ts-node": "10.9.1", + "tslib": "^2.3.0", + "typescript": "~5.9.2", + "typescript-eslint": "^8.40.0" + } +} diff --git a/kujali/tsconfig.base.json b/kujali/tsconfig.base.json new file mode 100644 index 00000000..b73cce6c --- /dev/null +++ b/kujali/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "rootDir": ".", + "sourceMap": true, + "declaration": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "es2015", + "module": "esnext", + "lib": ["es2020", "dom"], + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "baseUrl": ".", + "paths": {} + }, + "exclude": ["node_modules", "tmp"] +} diff --git a/libs/features/budgetting/budgets/src/lib/components/budget-table/budget-table.component.ts b/libs/features/budgetting/budgets/src/lib/components/budget-table/budget-table.component.ts index 77d8fee7..19ccc93c 100644 --- a/libs/features/budgetting/budgets/src/lib/components/budget-table/budget-table.component.ts +++ b/libs/features/budgetting/budgets/src/lib/components/budget-table/budget-table.component.ts @@ -1,34 +1,45 @@ -import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, EventEmitter, inject, Input, OnChanges, Output, signal, SimpleChanges, ViewChild } from '@angular/core'; import { MatTable, 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'; import { CreateBudgetModalComponent } from '../create-budget-modal/create-budget-modal.component'; import { ChildBudgetsModalComponent } from '../../modals/child-budgets-modal/child-budgets-modal.component'; +interface BudgetsData { + overview: BudgetRecord[]; + budgets: any[]; +} + @Component({ selector: 'app-budget-table', templateUrl: './budget-table.component.html', styleUrls: ['./budget-table.component.scss'], + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, // Zoneless-ready with OnPush }) - -export class BudgetTableComponent { - - private _sbS = new SubSink(); - - @Input() budgets$: Observable<{overview: BudgetRecord[], budgets: any[]}>; +export class BudgetTableComponent implements AfterViewInit, OnChanges { + // Signal-based inputs - using regular @Input() but converting to signals internally + @Input() budgets: BudgetsData | null = null; @Input() canPromote = false; @Output() doPromote: EventEmitter = new EventEmitter(); + // Injected dependencies using inject() + private _router = inject(Router); + private _dialog = inject(MatDialog); + private _cdr = inject(ChangeDetectorRef); + + // Signal-based internal state + private _budgetsSignal = signal(null); + private _canPromoteSignal = signal(false); + + // Data source as a regular property (not signal) for MatTable compatibility dataSource = new MatTableDataSource(); displayedColumns: string[] = ['name', 'status', 'startYear', 'duration', 'actions']; @@ -36,25 +47,47 @@ export class BudgetTableComponent { @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild('sort', { static: true }) sort: MatSort; - overviewBudgets: BudgetRecord[] = []; - - constructor(private _router$$: Router, - private _dialog: MatDialog, - ) { } + // Signal for overview budgets + overviewBudgets = signal([]); + + constructor() { + // Effect to reactively update data source when budgets signal changes + effect(() => { + const budgetsData = this._budgetsSignal(); + if (budgetsData) { + this.overviewBudgets.set(budgetsData.overview); + this.dataSource.data = budgetsData.budgets; + + // Update paginator and sort if already initialized + if (this.paginator) { + this.dataSource.paginator = this.paginator; + } + if (this.sort) { + this.dataSource.sort = this.sort; + } + + // Mark for check in OnPush mode + this._cdr.markForCheck(); + } + }); + } - ngOnInit(): void { - this._sbS.sink = this.budgets$.pipe(tap((o) => { - this.overviewBudgets = o.overview; - this.dataSource.data = o.budgets; - })).subscribe(); + ngOnChanges(changes: SimpleChanges): void { + // Convert @Input() changes to signals + if (changes['budgets']) { + this._budgetsSignal.set(changes['budgets'].currentValue); + } + if (changes['canPromote']) { + this._canPromoteSignal.set(changes['canPromote'].currentValue); + } } /** - * 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': @@ -81,8 +114,9 @@ export class BudgetTableComponent { } promote() { - if (this.canPromote) + if (this._canPromoteSignal()) { this.doPromote.emit(); + } } /** Open share screen to configure budget access. */ @@ -106,7 +140,8 @@ export class BudgetTableComponent { openChildBudgetDialog(parent : Budget): void { - let children: any = this.overviewBudgets.find((budget) => budget.budget.id === parent.id)!?.children; + const overview = this.overviewBudgets(); + let children: any = overview.find((budget) => budget.budget.id === parent.id)!?.children; children = children?.map((child) => child.budget) this._dialog.open(ChildBudgetsModalComponent, { height: 'fit-content', @@ -116,11 +151,11 @@ export class BudgetTableComponent { } goToDetail(budgetId: string, action: string) { - this._router$$.navigate(['budgets', budgetId, action]).then(() => this._dialog.closeAll()); + this._router.navigate(['budgets', budgetId, action]).then(() => this._dialog.closeAll()); } deleteBudget(budget: Budget) { - + // Delete functionality } translateStatus(status: number) { diff --git a/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.html b/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.html index f291feb4..0a1b4f7e 100644 --- a/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.html +++ b/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.html @@ -12,13 +12,13 @@ -
+
- +
\ No newline at end of file diff --git a/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.ts b/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.ts index 887a1d2e..f56c4527 100644 --- a/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.ts +++ b/libs/features/budgetting/budgets/src/lib/pages/select-budget/select-budget.component.ts @@ -1,8 +1,8 @@ -import { Component, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { SubSink } from 'subsink'; import { cloneDeep as ___cloneDeep, flatMap as __flatMap } from 'lodash'; -import { Observable, combineLatest, map, tap } from 'rxjs'; import { Logger } from '@iote/bricks-angular'; @@ -12,43 +12,76 @@ import { BudgetsStore, OrgBudgetsStore } from '@app/state/finance/budgetting/bud import { CreateBudgetModalComponent } from '../../components/create-budget-modal/create-budget-modal.component'; +interface AllBudgetsData { + overview: BudgetRecord[]; + budgets: any[]; +} @Component({ selector: 'app-select-budget', templateUrl: './select-budget.component.html', styleUrls: ['./select-budget.component.scss', '../../components/budget-view-styles.scss'], + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, // Zoneless-ready with OnPush }) /** List of all active budgets on the system. */ -export class SelectBudgetPageComponent implements OnInit +export class SelectBudgetPageComponent implements OnInit, OnDestroy { - /** Overview which contains all budgets of an organisation */ - overview$!: Observable; - sharedBudgets$: Observable; - - showFilter = false; - - // budgetsLoaded: boolean = false; - - allBudgets$: Observable<{overview: BudgetRecord[], budgets: any[]}>; + private _sbS = new SubSink(); + + // Injected dependencies using inject() + private _orgBudgets$$ = inject(OrgBudgetsStore); + private _budgets$$ = inject(BudgetsStore); + private _dialog = inject(MatDialog); + private _logger = inject(Logger); + private _cdr = inject(ChangeDetectorRef); + + // Signal-based state + showFilter = signal(false); + + // Signals for observable data + overview = signal(null); + sharedBudgets = signal([]); + + // Computed signal that combines overview and budgets + allBudgets = computed(() => { + const overviewValue = this.overview(); + const budgetsValue = this.sharedBudgets(); + + if (!overviewValue || !budgetsValue) { + return null; + } + + const flatOverview = __flatMap(overviewValue); + const flatBudgets = __flatMap(budgetsValue); + + const transformedBudgets = flatBudgets.map((budget: any) => { + budget['endYear'] = budget.startYear + budget.duration - 1; + return budget; + }); - constructor(private _orgBudgets$$: OrgBudgetsStore, - private _budgets$$: BudgetsStore, - private _dialog: MatDialog, - private _logger: Logger) - { } + return { + overview: flatOverview, + budgets: transformedBudgets + }; + }); + + ngOnInit(): void { + // Convert observables to signals using subscriptions + this._sbS.sink = this._orgBudgets$$.get().subscribe(overview => { + this.overview.set(overview); + this._cdr.markForCheck(); // Trigger change detection with OnPush + }); - ngOnInit() { - this.overview$ = this._orgBudgets$$.get(); - this.sharedBudgets$ = this._budgets$$.get(); + this._sbS.sink = this._budgets$$.get().subscribe(budgets => { + this.sharedBudgets.set(budgets); + this._cdr.markForCheck(); // Trigger change detection with OnPush + }); + } - 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} - })); + ngOnDestroy(): void { + this._sbS.unsubscribe(); } applyFilter(event: Event) { @@ -56,12 +89,12 @@ export class SelectBudgetPageComponent implements OnInit // this.dataSource.filter = filterValue.trim().toLowerCase(); } - fieldsFilter(value: (Invoice) => boolean) { + fieldsFilter(value: (any) => boolean) { // this.filter$$.next(value); } - toogleFilter(value) { - // this.showFilter = value + toogleFilter(value: boolean) { + this.showFilter.set(value); } openDialog(parent : Budget | false): void @@ -105,4 +138,4 @@ export class SelectBudgetPageComponent implements OnInit this._logger.log(() => `Updated Budget with id ${toSave.id}. Set as an active budget for this org.`) }); } -} \ No newline at end of file +} diff --git a/libs/model/budgetting/notes/budget-notes/README.md b/libs/model/budgetting/notes/budget-notes/README.md new file mode 100644 index 00000000..dcd43d79 --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/README.md @@ -0,0 +1,8 @@ +# model-budgetting-notes-budget-notes + +This library contains the command and handler for adding notes to budgets. + +## Running unit tests + +Run `nx test model-budgetting-notes-budget-notes` to execute the unit tests. + diff --git a/libs/model/budgetting/notes/budget-notes/jest.config.ts b/libs/model/budgetting/notes/budget-notes/jest.config.ts new file mode 100644 index 00000000..7830023c --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/jest.config.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +export default { + displayName: 'model-budgetting-notes-budget-notes', + preset: '../../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + }, + coverageDirectory: '../../../../../coverage/libs/model/budgetting/notes/budget-notes', + transform: { + '^.+\\.(ts|mjs|js|html)$': 'jest-preset-angular', + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; + diff --git a/libs/model/budgetting/notes/budget-notes/project.json b/libs/model/budgetting/notes/budget-notes/project.json new file mode 100644 index 00000000..0cf5e036 --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/project.json @@ -0,0 +1,29 @@ +{ + "name": "model-budgetting-notes-budget-notes", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/model/budgetting/notes/budget-notes/src", + "prefix": "app", + "targets": { + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/model/budgetting/notes/budget-notes/jest.config.ts", + "passWithNoTests": true + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/model/budgetting/notes/budget-notes/**/*.ts", + "libs/model/budgetting/notes/budget-notes/**/*.html" + ] + } + } + }, + "tags": [] +} + diff --git a/libs/model/budgetting/notes/budget-notes/src/index.ts b/libs/model/budgetting/notes/budget-notes/src/index.ts new file mode 100644 index 00000000..a05d4939 --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/domain/add-note.command'; +export * from './lib/domain/add-note-result.interface'; +export * from './lib/domain/add-note.handler'; + diff --git a/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note-result.interface.ts b/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note-result.interface.ts new file mode 100644 index 00000000..42081c5c --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note-result.interface.ts @@ -0,0 +1,20 @@ +import { IObject } from '@iote/bricks'; + +/** + * Result returned after adding a note to a budget. + */ +export interface AddNoteToBudgetResult extends IObject +{ + /** Organization ID */ + orgId: string; + + /** Budget ID */ + budgetId: string; + + /** Success indicator */ + success: boolean; + + /** Message describing the result */ + message?: string; +} + diff --git a/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note.command.ts b/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note.command.ts new file mode 100644 index 00000000..404002c4 --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note.command.ts @@ -0,0 +1,17 @@ +/** + * Command to add a note to a budget. + * + * Encapsulates the data needed to add a note to a budget. + */ +export interface AddNoteToBudgetCommand +{ + /** Organization ID */ + orgId: string; + + /** Budget ID */ + budgetId: string; + + /** Note content */ + noteContent: string; +} + diff --git a/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note.handler.ts b/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note.handler.ts new file mode 100644 index 00000000..8f60c5b9 --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/src/lib/domain/add-note.handler.ts @@ -0,0 +1,147 @@ +import { HandlerTools } from '@iote/cqrs'; +import { FunctionContext, FunctionHandler } from '@ngfi/functions'; + +import { Notes } from '@app/model/finance/notes'; + +import { AddNoteToBudgetCommand } from './add-note.command'; +import { AddNoteToBudgetResult } from './add-note-result.interface'; + +/** + * Generic command handler interface. + */ +export interface ICommandHandler +{ + execute(command: TCommand): Promise; +} + +/** + * Repository path for budget notes. + */ +const BUDGET_NOTES_REPO = (orgId: string, budgetId: string) => `orgs/${orgId}/budgets/${budgetId}/config`; + +/** + * Handler for adding a note to a budget. + * + * Implements the CQRS command pattern for adding notes to budgets. + */ +export class AddNoteToBudgetHandler extends FunctionHandler +{ + /** + * Executes the command to add a note to a budget. + * + * @param command - The command containing orgId, budgetId, and noteContent + * @param context - The function context + * @param tools - Handler tools including logger and repository access + * @returns Promise resolving to the result of the operation + */ + public async execute( + command: AddNoteToBudgetCommand, + context: FunctionContext, + tools: HandlerTools + ): Promise + { + tools.Logger.log(() => `[AddNoteToBudgetHandler].execute: Adding note to budget ${command.budgetId} for org ${command.orgId}`); + + // Validate command data + if (!command.noteContent || command.noteContent.trim().length === 0) + { + const errorMessage = 'Note content cannot be empty'; + tools.Logger.error(() => `[AddNoteToBudgetHandler].execute: ${errorMessage}`); + + return { + id: '', + orgId: command.orgId, + budgetId: command.budgetId, + success: false, + message: errorMessage + }; + } + + if (!command.orgId || command.orgId.trim().length === 0) + { + const errorMessage = 'Organization ID cannot be empty'; + tools.Logger.error(() => `[AddNoteToBudgetHandler].execute: ${errorMessage}`); + + return { + id: '', + orgId: command.orgId || '', + budgetId: command.budgetId || '', + success: false, + message: errorMessage + }; + } + + if (!command.budgetId || command.budgetId.trim().length === 0) + { + const errorMessage = 'Budget ID cannot be empty'; + tools.Logger.error(() => `[AddNoteToBudgetHandler].execute: ${errorMessage}`); + + return { + id: '', + orgId: command.orgId, + budgetId: command.budgetId || '', + success: false, + message: errorMessage + }; + } + + try + { + // Get the repository for budget notes + const notesRepo = tools.getRepository(BUDGET_NOTES_REPO(command.orgId, command.budgetId)); + + // Get existing notes or create new + let existingNotes: Notes | null = null; + + try + { + existingNotes = await notesRepo.getDocumentById('notes'); + } + catch (error) + { + tools.Logger.log(() => `[AddNoteToBudgetHandler].execute: No existing notes found, creating new`); + // Notes document doesn't exist yet, will create new one + } + + // Prepare the notes object + const notesToSave: Notes = existingNotes + ? { + ...existingNotes, + note: existingNotes.note + ? `${existingNotes.note}\n\n${command.noteContent.trim()}` + : command.noteContent.trim() + } + : { + id: 'notes', + note: command.noteContent.trim() + }; + + // Save the notes + await notesRepo.write(notesToSave, 'notes'); + + tools.Logger.log(() => `[AddNoteToBudgetHandler].execute: Successfully added note to budget ${command.budgetId}`); + + return { + id: 'notes', + orgId: command.orgId, + budgetId: command.budgetId, + success: true, + message: 'Note added successfully' + }; + } + catch (error) + { + const errorMessage = `Failed to add note: ${error instanceof Error ? error.message : 'Unknown error'}`; + tools.Logger.error(() => `[AddNoteToBudgetHandler].execute: ${errorMessage}`); + + return { + id: '', + orgId: command.orgId, + budgetId: command.budgetId, + success: false, + message: errorMessage + }; + } + } +} + diff --git a/libs/model/budgetting/notes/budget-notes/src/test-setup.ts b/libs/model/budgetting/notes/budget-notes/src/test-setup.ts new file mode 100644 index 00000000..ab68e1eb --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/src/test-setup.ts @@ -0,0 +1,2 @@ +import 'jest-preset-angular/setup-jest'; + diff --git a/libs/model/budgetting/notes/budget-notes/tsconfig.json b/libs/model/budgetting/notes/budget-notes/tsconfig.json new file mode 100644 index 00000000..56b69197 --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} + diff --git a/libs/model/budgetting/notes/budget-notes/tsconfig.lib.json b/libs/model/budgetting/notes/budget-notes/tsconfig.lib.json new file mode 100644 index 00000000..616bd0e9 --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/test-setup.ts", + "src/**/*.spec.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} + diff --git a/libs/model/budgetting/notes/budget-notes/tsconfig.spec.json b/libs/model/budgetting/notes/budget-notes/tsconfig.spec.json new file mode 100644 index 00000000..6da2c4cc --- /dev/null +++ b/libs/model/budgetting/notes/budget-notes/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} + diff --git a/tsconfig.base.json b/tsconfig.base.json index 493eadcc..d32ff21a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -254,6 +254,9 @@ "@app/model/finance/planning/time": [ "libs/model/finance/planning/time/src/index.ts" ], + "@app/model/budgetting/notes": [ + "libs/model/budgetting/notes/budget-notes/src/index.ts" + ], "@app/model/organisation": ["libs/model/organisation/main/src/index.ts"], "@app/model/roles": ["libs/model/roles/base/src/index.ts"], "@app/model/tags": ["libs/model/tags/base/src/index.ts"],