feat: Armarium v1.1.0 — dashboard, auth, 2FA, SMTP, settings, deploy

Dashboard:
- ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart
- KPI cards: income, fixed costs, savings rate with configurable goal
- Greeting with time-of-day and locale-aware date/time display

Authentication & security:
- Email-based login (no username), case-insensitive lookup
- JWT access/refresh tokens with rotation and blacklist
- TOTP 2FA with QR code, backup codes (copy + PDF export)
- 2FA recovery via email code
- Cloudflare Turnstile CAPTCHA on login and register

Email flows:
- Email verification on registration (24h token)
- Password reset flow (15min token, anti-enumeration)
- Brevo SMTP integration with HTML + plaintext email templates
- Notification emails: 2FA recovery, password changed, email changed

Settings page:
- 2FA management (enable/disable, QR, backup codes)
- Active sessions list with per-device revoke
- Data export: ZIP with 6 PDFs via fpdf2
- Notification preferences (3 toggles)
- Danger zone: account deletion with mandatory export + confirmation phrase

UI & layout:
- Sidebar with collapsible/flyout mode, Angular signal-based dropdowns
- Dark mode (class-based), language switcher (DE/FR/IT/EN)
- Mobile-responsive layout with touch-friendly targets
- Roboto font via @fontsource (GDPR-compliant, no Google CDN)
- Pure Tailwind CSS v3

Infrastructure:
- Forgejo Actions CI/CD pipeline (auto-deploy on push to main)
- Gunicorn + Nginx + PostgreSQL production setup
- Rate limiting, HSTS, secure cookies, CSRF protection
This commit is contained in:
Daniel Krähenbühl
2026-05-25 22:45:18 +02:00
parent 807ebc41a5
commit 1a7ef09805
150 changed files with 22862 additions and 3 deletions
Submodule frontend deleted from e38e9877c0
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+59
View File
@@ -0,0 +1,59 @@
# BudgetFrontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+82
View File
@@ -0,0 +1,82 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "97da586f-bf37-4b8e-beae-24c4d04eb25b"
},
"newProjectRoot": "projects",
"projects": {
"budget-frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "budget-frontend:build:production"
},
"development": {
"buildTarget": "budget-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+10713
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "budget-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@fontsource/roboto": "^5.2.10",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@types/qrcode": "^1.5.6",
"apexcharts": "^3.46.0",
"jspdf": "^4.2.1",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.0",
"autoprefixer": "^10.4.27",
"jsdom": "^28.0.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+10
View File
@@ -0,0 +1,10 @@
{
"/api": {
"target": "http://localhost:8000",
"secure": false
},
"/media": {
"target": "http://localhost:8000",
"secure": false
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,227 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'accounts.title' | translate }}</h1>
</div>
<button (click)="openCreateModal()"
class="inline-flex items-center gap-1.5 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
<!-- Flowbite: outline/general/plus -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
</svg>
{{ 'accounts.add' | translate }}
</button>
</div>
<!-- Tabelle -->
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-5 py-3">{{ 'common.name' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'accounts.col_type' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'accounts.col_balance' | translate }}</th>
<th scope="col" class="px-5 py-3"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (account of accounts(); track account.id) {
<tr class="border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-5 py-3 font-medium text-gray-900 dark:text-white whitespace-nowrap">
{{ account.name }}
</td>
<td class="px-5 py-3">
@if (account.account_type === 'asset') {
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{{ 'accounts.type_asset' | translate }}
</span>
} @else {
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-300">
{{ 'accounts.type_revenue' | translate }}
</span>
}
</td>
<td class="px-5 py-3 font-semibold text-violet-600 dark:text-violet-400">
{{ account.balance | number:'1.2-2' }} CHF
</td>
<td class="px-5 py-3">
<div class="flex items-center justify-end gap-1">
<button (click)="openEditModal(account)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-white transition-colors">
<!-- Flowbite: outline/edit/pen-to-square -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
</button>
<button (click)="openDeleteModal(account.id)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="px-5 py-10 text-center text-sm text-gray-400 dark:text-gray-500">
{{ 'accounts.no_accounts' | translate }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- CREATE MODAL -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeCreateModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'accounts.create_title' | translate }}</h3>
<button type="button" (click)="closeCreateModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="newName" [placeholder]="'accounts.placeholder_name' | translate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_balance' | translate }}</label>
<input type="number" [(ngModel)]="newBalance" placeholder="0.00"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_type' | translate }}</label>
<select [(ngModel)]="newType"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="asset">{{ 'accounts.type_asset' | translate }}</option>
<option value="revenue">{{ 'accounts.type_revenue' | translate }}</option>
</select>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeCreateModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="createAccount()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.create' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- EDIT MODAL -->
@if (showEditModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeEditModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'accounts.edit_title' | translate }}</h3>
<button type="button" (click)="closeEditModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="editName"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_balance' | translate }}</label>
<input type="number" [(ngModel)]="editBalance"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'accounts.label_type' | translate }}</label>
<select [(ngModel)]="editType"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="asset">{{ 'accounts.type_asset' | translate }}</option>
<option value="revenue">{{ 'accounts.type_revenue' | translate }}</option>
</select>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeEditModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="updateAccount()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.save' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- DELETE MODAL -->
@if (showDeleteModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeDeleteModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.delete_confirm_title' | translate }}</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.delete_confirm_text' | translate }}</p>
<div class="flex items-center justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="confirmDelete()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900">
{{ 'common.delete' | translate }}
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountList } from './account-list';
describe('AccountList', () => {
let component: AccountList;
let fixture: ComponentFixture<AccountList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AccountList],
}).compileComponents();
fixture = TestBed.createComponent(AccountList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,122 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
@Component({
selector: 'app-account-list',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './account-list.html',
styleUrl: './account-list.css',
})
export class AccountList implements OnInit {
accounts = signal<any[]>([]);
// Create Modal
showCreateModal = signal(false);
newName = '';
newBalance = 0;
newType = 'asset';
// Edit Modal
showEditModal = signal(false);
editId = 0;
// Delete Modal
showDeleteModal = signal(false);
deleteTargetId = 0;
editName = '';
editBalance = 0;
editType = 'asset';
constructor(private api: ApiService) {}
ngOnInit(): void {
this.loadAccounts();
}
loadAccounts() {
this.api.getAccounts().subscribe({
next: (data) => this.accounts.set(data.filter((a: any) => a.account_type === 'asset' || a.account_type === 'revenue')),
error: (err) => console.error('Fehler:', err)
});
}
// Create
openCreateModal() {
this.showCreateModal.set(true);
}
closeCreateModal() {
this.showCreateModal.set(false);
this.newName = '';
this.newBalance = 0;
this.newType = 'asset';
}
createAccount() {
if (!this.newName) return;
this.api.createAccount({
name: this.newName,
balance: this.newBalance,
account_type: this.newType
}).subscribe({
next: () => {
this.loadAccounts();
this.closeCreateModal();
},
error: (err) => console.error('Fehler beim Erstellen:', err)
});
}
// Edit
openEditModal(account: any) {
this.editId = account.id;
this.editName = account.name;
this.editBalance = account.balance;
this.editType = account.account_type;
this.showEditModal.set(true);
}
closeEditModal() {
this.showEditModal.set(false);
}
updateAccount() {
if (!this.editName) return;
this.api.updateAccount(this.editId, {
name: this.editName,
balance: this.editBalance,
account_type: this.editType
}).subscribe({
next: () => {
this.loadAccounts();
this.closeEditModal();
},
error: (err) => console.error('Fehler beim Bearbeiten:', err)
});
}
// Delete
openDeleteModal(id: number) {
this.deleteTargetId = id;
this.showDeleteModal.set(true);
}
closeDeleteModal() {
this.showDeleteModal.set(false);
this.deleteTargetId = 0;
}
confirmDelete() {
this.api.deleteAccount(this.deleteTargetId).subscribe({
next: () => {
this.loadAccounts();
this.closeDeleteModal();
},
error: (err) => console.error('Error deleting account:', err)
});
}
}
+44
View File
@@ -0,0 +1,44 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, importProvidersFrom, APP_INITIALIZER } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { TranslateModule, TranslateLoader, TranslateService } from '@ngx-translate/core';
import { TranslateHttpLoader, TRANSLATE_HTTP_LOADER_CONFIG } from '@ngx-translate/http-loader';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
const SUPPORTED_LANGS = ['de', 'fr', 'it', 'en'];
function preloadTranslations(translate: TranslateService): () => Promise<any> {
return () => {
const stored = localStorage.getItem('app_language');
const browser = navigator.language?.split('-')[0].toLowerCase();
const lang = SUPPORTED_LANGS.includes(stored ?? '') ? stored!
: SUPPORTED_LANGS.includes(browser) ? browser
: 'de';
translate.setDefaultLang('de');
return translate.use(lang).toPromise();
};
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
importProvidersFrom(
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateHttpLoader,
},
})
),
{ provide: TRANSLATE_HTTP_LOADER_CONFIG, useValue: { prefix: '/assets/i18n/', suffix: '.json' } },
{
provide: APP_INITIALIZER,
useFactory: preloadTranslations,
deps: [TranslateService],
multi: true,
},
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+40
View File
@@ -0,0 +1,40 @@
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
import { Shell } from './layout/shell/shell';
import { Login } from './auth/login/login';
import { Register } from './auth/register/register';
import { ForgotPassword } from './auth/forgot-password/forgot-password';
import { ResetPassword } from './auth/reset-password/reset-password';
import { VerifyEmail } from './auth/verify-email/verify-email';
import { Dashboard } from './dashboard/dashboard';
import { AccountList } from './accounts/account-list/account-list';
import { Budgets } from './budgets/budgets';
import { TransactionList } from './transactions/transaction-list/transaction-list';
import { ExpenseList } from './expenses/expense-list/expense-list';
import { Profile } from './profile/profile';
import { Settings } from './settings/settings';
import { Calendar } from './calendar/calendar';
export const routes: Routes = [
{ path: 'login', component: Login },
{ path: 'register', component: Register },
{ path: 'forgot-password', component: ForgotPassword },
{ path: 'reset-password', component: ResetPassword },
{ path: 'verify-email', component: VerifyEmail },
{
path: '',
component: Shell,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: Dashboard },
{ path: 'accounts', component: AccountList },
{ path: 'budgets', component: Budgets },
{ path: 'expenses', component: ExpenseList },
{ path: 'transactions', component: TransactionList },
{ path: 'profile', component: Profile },
{ path: 'settings', component: Settings },
{ path: 'calendar', component: Calendar },
],
},
{ path: '**', redirectTo: 'dashboard' },
];
+23
View File
@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, budget-frontend');
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}
@@ -0,0 +1,96 @@
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-8">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-6">
<img src="assets/Logo_vertikal.svg" alt="Armarium" class="h-16 mx-auto mb-3 dark:invert" />
<p class="text-gray-500 dark:text-gray-400">{{ 'auth.forgot_password_tagline' | translate }}</p>
</div>
<!-- Card -->
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-6">
<!-- Toolbar -->
<div class="flex items-center justify-between mb-5">
<app-lang-switcher />
<button type="button" (click)="themeService.toggle()"
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
@if (themeService.isDark()) {
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M13 3a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V3ZM6.343 4.929A1 1 0 0 0 4.93 6.343l1.414 1.414a1 1 0 0 0 1.414-1.414L6.343 4.929Zm12.728 1.414a1 1 0 0 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 1.414 1.414l1.414-1.414ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm-9 4a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H3Zm16 0a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2h-2ZM7.757 17.657a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414Zm9.9-1.414a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM13 19a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Z" clip-rule="evenodd"/>
</svg>
} @else {
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
</svg>
}
</button>
</div>
@if (!sent()) {
<!-- Heading -->
<h1 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">
{{ 'auth.forgot_password' | translate }}
</h1>
<p class="mb-5 text-gray-500 dark:text-gray-400">
{{ 'auth.forgot_password_hint' | translate }}
</p>
<!-- Email -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'auth.email' | translate }}
</label>
<input type="email" [(ngModel)]="email" (keyup.enter)="submit()" autocomplete="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<!-- Error -->
@if (error()) {
<div class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ error() | translate }}
</div>
}
<!-- Submit -->
<button (click)="submit()" [disabled]="loading()"
class="mt-4 w-full rounded-lg bg-violet-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
@if (loading()) {
<span class="inline-flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{{ 'auth.sending' | translate }}
</span>
} @else {
{{ 'auth.send_reset_link' | translate }}
}
</button>
} @else {
<!-- Sent state -->
<div class="text-center py-4">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-900">
<svg class="w-6 h-6 text-violet-600 dark:text-violet-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 0 0 2.22 0L21 8M5 19h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2Z"/>
</svg>
</div>
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ 'auth.reset_link_sent' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.reset_link_sent_hint' | translate }}</p>
</div>
}
<!-- Back to login -->
<p class="mt-5 text-center text-sm text-gray-500 dark:text-gray-400">
<a routerLink="/login" class="font-medium text-violet-700 hover:underline dark:text-violet-500">
{{ 'auth.back_to_login' | translate }}
</a>
</p>
</div>
</div>
</div>
@@ -0,0 +1,49 @@
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
import { signal } from '@angular/core';
@Component({
selector: 'app-forgot-password',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher],
templateUrl: './forgot-password.html',
})
export class ForgotPassword {
email = '';
loading = signal(false);
sent = signal(false);
error = signal('');
constructor(
private api: ApiService,
private langService: LanguageService,
public themeService: ThemeService,
) {
this.langService.init();
}
submit(): void {
this.error.set('');
if (!this.email.trim()) {
this.error.set('auth.errors.fields_required');
return;
}
this.loading.set(true);
this.api.requestPasswordReset(this.email.trim()).subscribe({
next: () => {
this.sent.set(true);
this.loading.set(false);
},
error: () => {
this.sent.set(true);
this.loading.set(false);
},
});
}
}
@@ -0,0 +1,74 @@
import { Component, signal, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LanguageService } from '../../services/language';
const LANGS = [
{ code: 'de', label: 'Deutsch' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
{ code: 'en', label: 'English' },
];
@Component({
selector: 'app-lang-switcher',
standalone: true,
imports: [CommonModule],
template: `
<div class="relative">
<button
(click)="open.set(!open())"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors shadow-sm">
<!-- Flowbite: outline/text/language -->
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m13 19 3.5-9 3.5 9m-6.125-2h5.25M3 7h7m0 0h2m-2 0c0 1.63-.793 3.926-2.239 5.655M7.5 6.818V5m.261 7.655C6.79 13.82 5.521 14.725 4 15m3.761-2.345L5 10m2.761 2.655L10.2 15"/>
</svg>
{{ current().toUpperCase() }}
<!-- Flowbite: outline/arrows/chevron-down -->
<svg class="w-3 h-3 text-gray-400 transition-transform" [class.rotate-180]="open()" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m8 10 4 4 4-4"/>
</svg>
</button>
@if (open()) {
<div class="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50">
@for (lang of langs; track lang.code) {
<button
(click)="select(lang.code)"
[class]="itemClass(lang.code)"
class="w-full text-left px-4 py-2 text-sm transition-colors">
{{ lang.label }}
</button>
}
</div>
}
</div>
`,
})
export class LangSwitcher {
protected readonly langs = LANGS;
protected readonly open = signal(false);
protected readonly current = signal('de');
constructor(private langService: LanguageService) {
this.current.set(langService.current);
}
protected itemClass(code: string): string {
return code === this.current()
? 'font-semibold text-violet-600 bg-violet-50'
: 'text-gray-700 hover:bg-gray-50';
}
protected select(code: string): void {
this.langService.setLanguage(code);
this.current.set(code);
this.open.set(false);
}
@HostListener('document:click', ['$event'])
onClickOutside(event: MouseEvent): void {
if (!(event.target as Element).closest('app-lang-switcher')) {
this.open.set(false);
}
}
}
+266
View File
@@ -0,0 +1,266 @@
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<img src="assets/Logo_vertikal.svg" alt="Armarium" class="h-20 mx-auto mb-3 dark:invert" />
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ 'auth.tagline_login' | translate }}</p>
</div>
<!-- Card -->
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-6">
<!-- Lang + Theme switcher -->
<div class="flex items-center justify-between mb-5">
<app-lang-switcher />
<button type="button" (click)="themeService.toggle()"
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
@if (themeService.isDark()) {
<!-- Flowbite: solid/weather/sun -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M13 3a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V3ZM6.343 4.929A1 1 0 0 0 4.93 6.343l1.414 1.414a1 1 0 0 0 1.414-1.414L6.343 4.929Zm12.728 1.414a1 1 0 0 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 1.414 1.414l1.414-1.414ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm-9 4a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H3Zm16 0a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2h-2ZM7.757 17.657a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414Zm9.9-1.414a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM13 19a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Z" clip-rule="evenodd"/>
</svg>
} @else {
<!-- Flowbite: solid/weather/moon -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
</svg>
}
</button>
</div>
<!-- ── Step: Credentials ── -->
@if (step() === 'credentials') {
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.email' | translate }}</label>
<input type="email" [(ngModel)]="email" (keyup.enter)="submit()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500"
placeholder="" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.password' | translate }}</label>
<div class="relative">
<input [type]="showPassword() ? 'text' : 'password'" [(ngModel)]="password" (keyup.enter)="submit()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500"
placeholder="" />
<button type="button" (click)="showPassword.set(!showPassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showPassword()) {
<!-- Flowbite: outline/general/eye-slash -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<!-- Flowbite: outline/general/eye -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
</div>
<div class="flex items-start gap-2">
<div class="flex h-5 items-center">
<input type="checkbox" [(ngModel)]="keepSignedIn"
class="h-4 w-4 rounded border border-gray-300 bg-gray-50 focus:ring-3 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-violet-600 cursor-pointer" />
</div>
<div class="text-sm">
<span class="block font-medium text-gray-700 dark:text-gray-300">{{ 'auth.keep_signed_in' | translate }}</span>
<span class="block text-xs text-gray-400 dark:text-gray-500">{{ 'auth.keep_signed_in_hint' | translate }}</span>
</div>
</div>
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400">{{ error() | translate }}</p>
}
<app-turnstile class="mt-4 block" (resolved)="turnstileToken = $event" />
<button type="button" (click)="submit()" [disabled]="loading() || !turnstileToken"
class="w-full rounded-lg bg-violet-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.sign_in' | translate) }}
</button>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
<a routerLink="/forgot-password" class="font-medium text-violet-700 hover:underline dark:text-violet-500">{{ 'auth.forgot_password' | translate }}</a>
</p>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ 'auth.no_account' | translate }}
<a routerLink="/register" class="font-medium text-violet-700 hover:underline dark:text-violet-500">{{ 'auth.sign_up' | translate }}</a>
</p>
</div>
}
<!-- ── Step: TOTP ── -->
@if (step() === 'totp') {
<div>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-violet-100 dark:bg-violet-900 flex items-center justify-center shrink-0">
<!-- Flowbite: outline/general/shield-check -->
<svg class="w-5 h-5 text-violet-600 dark:text-violet-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.5 11.5 11 13l4-3.5M12 20a16.405 16.405 0 0 1-5.092-5.804A16.694 16.694 0 0 1 5 6.666L12 4l7 2.667a16.695 16.695 0 0 1-1.908 7.529A16.406 16.406 0 0 1 12 20Z"/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.totp_title' | translate }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.totp_hint' | translate }}</p>
</div>
<!-- Countdown ring -->
<div class="relative flex items-center justify-center w-10 h-10 shrink-0">
<svg class="w-10 h-10 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#e5e7eb" stroke-width="3" class="dark:stroke-gray-600"/>
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#7c3aed" stroke-width="3"
stroke-dasharray="100"
[attr.stroke-dashoffset]="countdownOffset()"
style="transition: stroke-dashoffset 1s linear;"/>
</svg>
<span class="absolute text-xs font-semibold text-violet-700 dark:text-violet-400">{{ totpCountdown() }}</span>
</div>
</div>
<!-- 6 digit inputs -->
<div class="flex justify-center gap-2 my-4 sm:gap-4">
@for (i of [0,1,2,3,4,5]; track i) {
<input
type="text"
inputmode="numeric"
maxlength="2"
class="otp-digit block h-10 w-10 sm:h-12 sm:w-12 rounded-lg border border-gray-300 bg-white py-3 text-center text-2xl font-extrabold text-gray-900 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500 caret-transparent"
(input)="onDigitInput(i, $event)"
(keydown)="onDigitKeydown(i, $event)"
(paste)="onDigitPaste($event)" />
}
</div>
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400 text-center mb-3">{{ error() | translate }}</p>
}
@if (loading()) {
<p class="text-sm text-center text-gray-400 mb-2">{{ 'auth.signing_in' | translate }}</p>
}
<p class="mt-4 rounded-lg bg-gray-100 p-4 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{{ 'auth.totp_no_device' | translate }}
<button type="button" (click)="goToBackup()"
class="font-medium text-violet-700 underline hover:no-underline dark:text-violet-500">
{{ 'auth.totp_use_backup' | translate }}
</button>
</p>
</div>
<button type="button" (click)="goToCredentials()" class="flex items-center justify-center gap-1.5 w-full mt-4 px-4 py-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 8-4 4 4 4"/>
</svg>
{{ 'auth.back_to_login' | translate }}
</button>
}
<!-- ── Step: Backup code ── -->
@if (step() === 'backup') {
<div>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/40 flex items-center justify-center shrink-0">
<!-- Flowbite: outline/general/lock -->
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14v3m-3-6V7a3 3 0 1 1 6 0v4m-8 0h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.totp_use_backup' | translate }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.backup_format_hint' | translate }}</p>
</div>
</div>
<input type="text" [(ngModel)]="backupCode" (keyup.enter)="submitBackup()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm font-mono text-center tracking-widest uppercase text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500"
/>
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400 text-center mt-2">{{ error() | translate }}</p>
}
<button type="button" (click)="submitBackup()" [disabled]="loading()"
class="w-full mt-4 rounded-lg bg-violet-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.sign_in' | translate) }}
</button>
<p class="mt-4 rounded-lg bg-gray-100 p-4 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{{ 'auth.totp_no_backup' | translate }}
<button type="button" (click)="goToRecovery()"
class="font-medium text-violet-700 underline hover:no-underline dark:text-violet-500">
{{ 'auth.recovery_title' | translate }}
</button>
</p>
</div>
<button type="button" (click)="goToCredentials()" class="flex items-center justify-center gap-1.5 w-full mt-4 px-4 py-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 8-4 4 4 4"/>
</svg>
{{ 'auth.back_to_login' | translate }}
</button>
}
<!-- ── Step: Recovery email ── -->
@if (step() === 'recovery') {
<div>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center shrink-0">
<!-- Flowbite: outline/general/envelope -->
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m3.5 5.5 7.893 6.036a1 1 0 0 0 1.214 0L20.5 5.5M4 19h16a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1Z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'auth.recovery_title' | translate }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.recovery_intro' | translate }}</p>
</div>
</div>
@if (!recoverySent()) {
<button type="button" (click)="sendRecovery()" [disabled]="loading()"
class="w-full rounded-lg bg-violet-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.recovery_send' | translate) }}
</button>
} @else {
<div class="mb-4 rounded-lg bg-gray-100 p-4 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-400">
<p>{{ 'auth.recovery_sent' | translate }}</p>
@if (maskedEmail()) {
<p class="font-medium text-gray-900 dark:text-white mt-1">{{ maskedEmail() }}</p>
}
<p class="mt-1">{{ 'auth.recovery_spam_hint' | translate }}</p>
</div>
<input type="text" [(ngModel)]="recoveryCode" (keyup.enter)="submitRecoveryCode()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm font-mono text-center tracking-widest uppercase text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500"
placeholder="XXXX-XXXX" maxlength="9" />
@if (error()) {
<p class="text-sm text-red-600 dark:text-red-400 text-center mt-2">{{ error() | translate }}</p>
}
<button type="button" (click)="submitRecoveryCode()" [disabled]="loading()"
class="w-full mt-3 rounded-lg bg-violet-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50">
{{ loading() ? ('auth.signing_in' | translate) : ('auth.recovery_confirm' | translate) }}
</button>
}
</div>
<button type="button" (click)="goToCredentials()" class="flex items-center justify-center gap-1.5 w-full mt-4 px-4 py-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 8-4 4 4 4"/>
</svg>
{{ 'auth.back_to_login' | translate }}
</button>
}
</div>
</div>
</div>
+249
View File
@@ -0,0 +1,249 @@
import { Component, OnInit, OnDestroy, ViewChild, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from '../../services/auth';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
import { TurnstileComponent } from '../turnstile/turnstile';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher, TurnstileComponent],
templateUrl: './login.html',
})
export class Login implements OnInit, OnDestroy {
@ViewChild(TurnstileComponent) private turnstileComp?: TurnstileComponent;
turnstileToken = '';
email = '';
password = '';
keepSignedIn = true;
showPassword = signal(false);
error = signal('');
loading = signal(false);
step = signal<'credentials' | 'totp' | 'backup' | 'recovery'>('credentials');
// TOTP digit inputs
digits: string[] = ['', '', '', '', '', ''];
// Backup code
backupCode = '';
// Recovery
recoverySent = signal(false);
maskedEmail = signal('');
recoveryCode = '';
// Countdown
totpCountdown = signal(30);
private pendingTempToken = '';
private countdownInterval?: ReturnType<typeof setInterval>;
constructor(
private auth: AuthService,
private api: ApiService,
private router: Router,
private langService: LanguageService,
public themeService: ThemeService,
) {}
ngOnInit(): void {
this.langService.init();
}
ngOnDestroy(): void {
clearInterval(this.countdownInterval);
}
// ── Credentials step ────────────────────────────────────────────────────────
submit(): void {
this.error.set('');
if (!this.email || !this.password) {
this.error.set('auth.errors.enter_credentials');
return;
}
this.loading.set(true);
this.auth.login(this.email, this.password, this.keepSignedIn, this.turnstileToken).subscribe({
next: (res) => {
if (res['2fa_required']) {
this.pendingTempToken = res.temp_token;
this.step.set('totp');
this.loading.set(false);
this.startCountdown();
setTimeout(() => this.focusDigit(0), 50);
} else {
this.router.navigate(['/dashboard']);
}
},
error: (err) => {
this.error.set(err.status === 400 ? 'auth.errors.captcha_failed' : 'auth.errors.invalid_credentials');
this.turnstileToken = '';
this.turnstileComp?.reset();
this.loading.set(false);
},
});
}
// ── TOTP digit input handling ────────────────────────────────────────────────
onDigitInput(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const val = input.value.replace(/\D/g, '').slice(-1);
this.digits[index] = val;
input.value = val;
if (val) {
if (index < 5) {
this.focusDigit(index + 1);
} else if (this.digits.every(d => d !== '')) {
this.submitTotp(this.digits.join(''));
}
}
}
onDigitKeydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace' && !this.digits[index] && index > 0) {
this.digits[index - 1] = '';
const prev = this.getDigitInput(index - 1);
if (prev) { prev.value = ''; prev.focus(); }
}
if (event.key === 'ArrowLeft' && index > 0) this.focusDigit(index - 1);
if (event.key === 'ArrowRight' && index < 5) this.focusDigit(index + 1);
}
onDigitPaste(event: ClipboardEvent): void {
event.preventDefault();
const text = event.clipboardData?.getData('text') ?? '';
const nums = text.replace(/\D/g, '').slice(0, 6);
for (let i = 0; i < 6; i++) {
this.digits[i] = nums[i] ?? '';
const el = this.getDigitInput(i);
if (el) el.value = this.digits[i];
}
const next = Math.min(nums.length, 5);
this.focusDigit(next);
if (nums.length === 6) this.submitTotp(nums);
}
private focusDigit(index: number): void {
this.getDigitInput(index)?.focus();
}
private getDigitInput(index: number): HTMLInputElement | null {
return document.querySelectorAll<HTMLInputElement>('.otp-digit')[index] ?? null;
}
// ── TOTP submit ──────────────────────────────────────────────────────────────
private submitTotp(code: string): void {
this.error.set('');
if (code.length !== 6) return;
this.loading.set(true);
this.api.login2FA(this.pendingTempToken, code).subscribe({
next: (tokens) => {
clearInterval(this.countdownInterval);
this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key);
this.router.navigate(['/dashboard']);
},
error: () => {
this.error.set('auth.errors.invalid_totp');
this.loading.set(false);
this.digits = ['', '', '', '', '', ''];
document.querySelectorAll<HTMLInputElement>('.otp-digit').forEach(el => el.value = '');
setTimeout(() => this.focusDigit(0), 50);
},
});
}
// ── Backup code submit ───────────────────────────────────────────────────────
submitBackup(): void {
this.error.set('');
if (!this.backupCode.trim()) return;
this.loading.set(true);
this.api.login2FA(this.pendingTempToken, this.backupCode.trim()).subscribe({
next: (tokens) => {
this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key);
this.router.navigate(['/dashboard']);
},
error: () => {
this.error.set('auth.errors.invalid_totp');
this.loading.set(false);
},
});
}
// ── Recovery email ───────────────────────────────────────────────────────────
sendRecovery(): void {
this.loading.set(true);
this.api.request2FARecovery(this.pendingTempToken).subscribe({
next: (res) => {
this.maskedEmail.set(res?.masked_email ?? '');
this.recoverySent.set(true);
this.loading.set(false);
},
error: () => { this.recoverySent.set(true); this.loading.set(false); },
});
}
submitRecoveryCode(): void {
this.error.set('');
if (!this.recoveryCode.trim()) return;
this.loading.set(true);
this.api.confirm2FARecovery(this.pendingTempToken, this.recoveryCode.trim()).subscribe({
next: (tokens) => {
this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key);
this.router.navigate(['/dashboard']);
},
error: () => {
this.error.set('auth.errors.invalid_totp');
this.loading.set(false);
},
});
}
// ── Countdown ────────────────────────────────────────────────────────────────
private startCountdown(): void {
this.updateCountdown();
this.countdownInterval = setInterval(() => this.updateCountdown(), 1000);
}
private updateCountdown(): void {
this.totpCountdown.set(30 - (Math.floor(Date.now() / 1000) % 30));
}
countdownOffset(): number {
return Math.round(100 * (1 - this.totpCountdown() / 30));
}
// ── Navigation helpers ───────────────────────────────────────────────────────
goToBackup(): void {
this.error.set('');
this.backupCode = '';
this.step.set('backup');
}
goToRecovery(): void {
this.error.set('');
this.recoverySent.set(false);
this.recoveryCode = '';
this.step.set('recovery');
}
goToCredentials(): void {
clearInterval(this.countdownInterval);
this.error.set('');
this.digits = ['', '', '', '', '', ''];
this.backupCode = '';
this.recoverySent.set(false);
this.step.set('credentials');
}
}
@@ -0,0 +1,142 @@
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-8">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-6">
<img src="assets/Logo_vertikal.svg" alt="Armarium" class="h-16 mx-auto mb-3 dark:invert" />
<p class="text-gray-500 dark:text-gray-400">{{ 'auth.tagline_register' | translate }}</p>
</div>
<!-- Card -->
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-6">
<!-- Toolbar -->
<div class="flex items-center justify-between mb-5">
<app-lang-switcher />
<button type="button" (click)="themeService.toggle()"
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
@if (themeService.isDark()) {
<!-- Flowbite: solid/weather/sun -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M13 3a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V3ZM6.343 4.929A1 1 0 0 0 4.93 6.343l1.414 1.414a1 1 0 0 0 1.414-1.414L6.343 4.929Zm12.728 1.414a1 1 0 0 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 1.414 1.414l1.414-1.414ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm-9 4a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H3Zm16 0a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2h-2ZM7.757 17.657a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414Zm9.9-1.414a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM13 19a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Z" clip-rule="evenodd"/>
</svg>
} @else {
<!-- Flowbite: solid/weather/moon -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
</svg>
}
</button>
</div>
<!-- Heading -->
<h1 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">
{{ 'auth.create_account' | translate }}
</h1>
<p class="mb-5 text-gray-500 dark:text-gray-400">
{{ 'auth.has_account' | translate }}
<a routerLink="/login" class="font-medium text-violet-700 hover:underline dark:text-violet-500">
{{ 'auth.sign_in' | translate }}
</a>
</p>
<!-- Fields -->
<div class="space-y-4">
<!-- Email -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'auth.email' | translate }}
</label>
<input type="email" [(ngModel)]="email" autocomplete="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<!-- Password -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'auth.password' | translate }}
</label>
<div class="relative">
<input [type]="showPassword() ? 'text' : 'password'" [(ngModel)]="password" autocomplete="new-password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button type="button" (click)="showPassword.set(!showPassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showPassword()) {
<!-- Flowbite: outline/general/eye-slash -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<!-- Flowbite: outline/general/eye -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">{{ 'auth.password_hint' | translate }}</p>
</div>
<!-- Confirm Password -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'auth.confirm_password' | translate }}
</label>
<div class="relative">
<input [type]="showConfirmPassword() ? 'text' : 'password'" [(ngModel)]="confirmPassword"
autocomplete="new-password" (keyup.enter)="submit()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button type="button" (click)="showConfirmPassword.set(!showConfirmPassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showConfirmPassword()) {
<!-- Flowbite: outline/general/eye-slash -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<!-- Flowbite: outline/general/eye -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
</div>
</div>
<!-- Error -->
@if (error()) {
<div class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<!-- Flowbite: outline/alerts/circle-exclamation -->
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ error() | translate }}
</div>
}
<app-turnstile class="mt-4 block" (resolved)="turnstileToken = $event" />
<!-- Submit -->
<button (click)="submit()" [disabled]="loading() || !turnstileToken"
class="mt-4 w-full rounded-lg bg-violet-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
@if (loading()) {
<span class="inline-flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{{ 'auth.creating_account' | translate }}
</span>
} @else {
{{ 'auth.sign_up' | translate }}
}
</button>
</div>
</div>
</div>
@@ -0,0 +1,69 @@
import { Component, OnInit, ViewChild, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from '../../services/auth';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
import { TurnstileComponent } from '../turnstile/turnstile';
@Component({
selector: 'app-register',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher, TurnstileComponent],
templateUrl: './register.html',
})
export class Register implements OnInit {
@ViewChild(TurnstileComponent) private turnstileComp?: TurnstileComponent;
turnstileToken = '';
email = '';
password = '';
confirmPassword = '';
showPassword = signal(false);
showConfirmPassword = signal(false);
error = signal('');
loading = signal(false);
constructor(
private auth: AuthService,
private router: Router,
private langService: LanguageService,
public themeService: ThemeService,
) {}
ngOnInit(): void {
const detected = this.langService.detectBrowserLanguage();
this.langService.setLanguage(detected);
}
submit(): void {
this.error.set('');
if (!this.email || !this.password) {
this.error.set('auth.errors.fields_required');
return;
}
if (this.password !== this.confirmPassword) {
this.error.set('auth.errors.passwords_mismatch');
return;
}
if (this.password.length < 8) {
this.error.set('auth.errors.password_too_short');
return;
}
this.loading.set(true);
this.auth.register(this.email, this.password, this.turnstileToken).subscribe({
next: () => this.router.navigate(['/login']),
error: (err) => {
const data = err.error;
const msg = err.status === 400 && data?.detail === 'Captcha verification failed.'
? 'auth.errors.captcha_failed'
: (data?.email?.[0] || data?.password?.[0] || 'auth.errors.registration_failed');
this.error.set(msg);
this.turnstileToken = '';
this.turnstileComp?.reset();
this.loading.set(false);
},
});
}
}
@@ -0,0 +1,139 @@
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-8">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-6">
<img src="assets/Logo_vertikal.svg" alt="Armarium" class="h-16 mx-auto mb-3 dark:invert" />
<p class="text-gray-500 dark:text-gray-400">{{ 'auth.reset_password_tagline' | translate }}</p>
</div>
<!-- Card -->
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-6">
<!-- Toolbar -->
<div class="flex items-center justify-between mb-5">
<app-lang-switcher />
<button type="button" (click)="themeService.toggle()"
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
@if (themeService.isDark()) {
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M13 3a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V3ZM6.343 4.929A1 1 0 0 0 4.93 6.343l1.414 1.414a1 1 0 0 0 1.414-1.414L6.343 4.929Zm12.728 1.414a1 1 0 0 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 1.414 1.414l1.414-1.414ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm-9 4a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H3Zm16 0a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2h-2ZM7.757 17.657a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414Zm9.9-1.414a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM13 19a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Z" clip-rule="evenodd"/>
</svg>
} @else {
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
</svg>
}
</button>
</div>
@if (!success()) {
<!-- Heading -->
<h1 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">
{{ 'auth.reset_password' | translate }}
</h1>
<p class="mb-5 text-gray-500 dark:text-gray-400">
{{ 'auth.reset_password_hint' | translate }}
</p>
<div class="space-y-4">
<!-- New password -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'auth.new_password' | translate }}
</label>
<div class="relative">
<input [type]="showPassword() ? 'text' : 'password'" [(ngModel)]="password" autocomplete="new-password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button type="button" (click)="showPassword.set(!showPassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showPassword()) {
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">{{ 'auth.password_hint' | translate }}</p>
</div>
<!-- Confirm password -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'auth.confirm_password' | translate }}
</label>
<div class="relative">
<input [type]="showConfirmPassword() ? 'text' : 'password'" [(ngModel)]="confirmPassword"
autocomplete="new-password" (keyup.enter)="submit()"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button type="button" (click)="showConfirmPassword.set(!showConfirmPassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showConfirmPassword()) {
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
</div>
</div>
<!-- Error -->
@if (error()) {
<div class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ error() | translate }}
</div>
}
<!-- Submit -->
<button (click)="submit()" [disabled]="loading() || !!error() && error() === 'auth.errors.token_missing'"
class="mt-4 w-full rounded-lg bg-violet-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
@if (loading()) {
<span class="inline-flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{{ 'auth.resetting' | translate }}
</span>
} @else {
{{ 'auth.reset_password' | translate }}
}
</button>
} @else {
<!-- Success state -->
<div class="text-center py-4">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ 'auth.reset_success' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.recovery_redirecting' | translate }}</p>
</div>
}
<!-- Back to login -->
<p class="mt-5 text-center text-sm text-gray-500 dark:text-gray-400">
<a routerLink="/login" class="font-medium text-violet-700 hover:underline dark:text-violet-500">
{{ 'auth.back_to_login' | translate }}
</a>
</p>
</div>
</div>
</div>
@@ -0,0 +1,70 @@
import { Component, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
import { LangSwitcher } from '../lang-switcher/lang-switcher';
@Component({
selector: 'app-reset-password',
standalone: true,
imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher],
templateUrl: './reset-password.html',
})
export class ResetPassword implements OnInit {
password = '';
confirmPassword = '';
showPassword = signal(false);
showConfirmPassword = signal(false);
loading = signal(false);
success = signal(false);
error = signal('');
private token = '';
constructor(
private api: ApiService,
private route: ActivatedRoute,
private router: Router,
private langService: LanguageService,
public themeService: ThemeService,
) {
this.langService.init();
}
ngOnInit(): void {
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
if (!this.token) {
this.error.set('auth.errors.token_missing');
}
}
submit(): void {
this.error.set('');
if (!this.password || !this.confirmPassword) {
this.error.set('auth.errors.fields_required');
return;
}
if (this.password !== this.confirmPassword) {
this.error.set('auth.errors.passwords_mismatch');
return;
}
if (this.password.length < 8) {
this.error.set('auth.errors.password_too_short');
return;
}
this.loading.set(true);
this.api.confirmPasswordReset(this.token, this.password).subscribe({
next: () => {
this.success.set(true);
this.loading.set(false);
setTimeout(() => this.router.navigate(['/login']), 3000);
},
error: () => {
this.error.set('auth.errors.reset_failed');
this.loading.set(false);
},
});
}
}
@@ -0,0 +1,53 @@
import { Component, Output, EventEmitter, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
@Component({
selector: 'app-turnstile',
standalone: true,
template: '<div #container></div>',
})
export class TurnstileComponent implements AfterViewInit, OnDestroy {
@ViewChild('container', { static: true }) private container!: ElementRef<HTMLDivElement>;
@Output() resolved = new EventEmitter<string>();
private widgetId = '';
private pollId?: ReturnType<typeof setTimeout>;
ngAfterViewInit(): void {
if (window.location.hostname === 'localhost') {
setTimeout(() => this.resolved.emit('dev-bypass'), 0);
return;
}
this.poll();
}
private poll(): void {
if ((window as any).turnstile) {
this.render();
} else {
this.pollId = setTimeout(() => this.poll(), 100);
}
}
private render(): void {
this.widgetId = (window as any).turnstile.render(this.container.nativeElement, {
sitekey: '0x4AAAAAADRzQr8OmvZ5s7NA',
theme: 'auto',
callback: (token: string) => this.resolved.emit(token),
'expired-callback': () => this.resolved.emit(''),
'error-callback': () => this.resolved.emit(''),
});
}
reset(): void {
if (this.widgetId && (window as any).turnstile) {
(window as any).turnstile.reset(this.widgetId);
}
}
ngOnDestroy(): void {
clearTimeout(this.pollId);
if (this.widgetId && (window as any).turnstile) {
(window as any).turnstile.remove(this.widgetId);
}
}
}
@@ -0,0 +1,69 @@
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-8">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-6">
<img src="assets/Logo_vertikal.svg" alt="Armarium" class="h-16 mx-auto mb-3 dark:invert" />
</div>
<!-- Card -->
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-6">
<!-- Toolbar -->
<div class="flex items-center justify-end mb-5">
<button type="button" (click)="themeService.toggle()"
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
@if (themeService.isDark()) {
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M13 3a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V3ZM6.343 4.929A1 1 0 0 0 4.93 6.343l1.414 1.414a1 1 0 0 0 1.414-1.414L6.343 4.929Zm12.728 1.414a1 1 0 0 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 1.414 1.414l1.414-1.414ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm-9 4a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H3Zm16 0a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2h-2ZM7.757 17.657a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414Zm9.9-1.414a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM13 19a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Z" clip-rule="evenodd"/>
</svg>
} @else {
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
</svg>
}
</button>
</div>
<div class="text-center py-4">
@if (state() === 'loading') {
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center">
<svg class="w-8 h-8 animate-spin text-violet-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
<p class="text-gray-600 dark:text-gray-300">{{ 'auth.verifying' | translate }}</p>
}
@if (state() === 'success') {
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ 'auth.email_verified' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.recovery_redirecting' | translate }}</p>
}
@if (state() === 'error') {
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{{ 'auth.verify_email_error' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'auth.errors.verify_failed' | translate }}</p>
}
</div>
<!-- Back to login -->
<p class="mt-5 text-center text-sm text-gray-500 dark:text-gray-400">
<a routerLink="/login" class="font-medium text-violet-700 hover:underline dark:text-violet-500">
{{ 'auth.back_to_login' | translate }}
</a>
</p>
</div>
</div>
</div>
@@ -0,0 +1,40 @@
import { Component, OnInit, signal } from '@angular/core';
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
import { ThemeService } from '../../services/theme';
@Component({
selector: 'app-verify-email',
standalone: true,
imports: [RouterModule, TranslateModule],
templateUrl: './verify-email.html',
})
export class VerifyEmail implements OnInit {
state = signal<'loading' | 'success' | 'error'>('loading');
constructor(
private api: ApiService,
private route: ActivatedRoute,
private router: Router,
private langService: LanguageService,
public themeService: ThemeService,
) {
this.langService.init();
}
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token') ?? '';
if (!token) {
this.state.set('error');
return;
}
this.api.verifyEmail(token).subscribe({
next: () => {
this.state.set('success');
setTimeout(() => this.router.navigate(['/login']), 3000);
},
error: () => this.state.set('error'),
});
}
}
+327
View File
@@ -0,0 +1,327 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'budgets.title' | translate }}</h1>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
{{ 'budgets.subtitle' | translate }}<span class="font-semibold text-violet-600">{{ grandTotal() | number:'1.2-2' }} CHF</span>
</p>
</div>
</div>
<!-- Kategorie-Gruppen -->
<div class="space-y-4">
@for (group of categoryGroups; track group.key) {
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<!-- Gruppen-Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">{{ group.label | translate }}</h2>
@if (budgetsForCategory(group.key).length > 0) {
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{{ budgetsForCategory(group.key).length }}
</span>
}
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-violet-600 dark:text-violet-400">
{{ totalForCategory(group.key) | number:'1.2-2' }} CHF
</span>
<button (click)="openCreateModal(group.key)"
class="inline-flex items-center gap-1.5 rounded-lg bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
<!-- Flowbite: outline/general/plus -->
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
</svg>
{{ 'budgets.add' | translate }}
</button>
</div>
</div>
<!-- Einträge -->
@if (budgetsForCategory(group.key).length > 0) {
<div class="divide-y divide-gray-100 dark:divide-gray-700">
@for (budget of budgetsForCategory(group.key); track budget.id) {
<div class="flex items-center justify-between px-5 py-3">
<div class="flex items-center gap-3 min-w-0 flex-1 mr-3">
<span class="w-2 h-2 rounded-full shrink-0"
[class]="budget.active ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-600'">
</span>
<div class="min-w-0">
<span class="text-sm font-medium text-gray-800 dark:text-white truncate block">{{ budget.name }}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 truncate block">{{ accountName(budget.account) }}</span>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
{{ budget.amount | number:'1.2-2' }} CHF
</span>
<button (click)="openEditModal(budget)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white transition-colors">
<!-- Flowbite: outline/edit/pen-to-square -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
</button>
<button (click)="openDeleteModal(budget.id)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</button>
</div>
</div>
}
</div>
} @else {
<div class="px-5 py-4 text-sm text-gray-400 dark:text-gray-500 italic">
{{ 'budgets.no_entries' | translate }}
</div>
}
</div>
}
</div>
<!-- NO ACCOUNTS MODAL -->
@if (showNoAccountsModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeNoAccountsModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/40 shrink-0">
<!-- Flowbite: outline/general/info-circle -->
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11h2v5m-2 0h4m-2.592-8.5h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.no_accounts_title' | translate }}</h3>
</div>
<button type="button" (click)="closeNoAccountsModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.no_accounts_text' | translate }}</p>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeNoAccountsModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<a routerLink="/accounts"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.go_to_accounts' | translate }}
</a>
</div>
</div>
</div>
</div>
}
<!-- CREATE MODAL -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeCreateModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ 'budgets.new_entry' | translate: { category: (labelForCategory(newCategory) | translate) } }}
</h3>
<button type="button" (click)="closeCreateModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<!-- Vorschläge -->
@if (currentSuggestions.length > 0) {
<div>
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">{{ 'budgets.label_suggestions' | translate }}</p>
<div class="flex flex-wrap gap-2">
@for (s of currentSuggestions; track s) {
<button (click)="applySuggestion(s)"
class="rounded-full border border-violet-300 px-3 py-1 text-xs font-medium text-violet-700 hover:bg-violet-50 dark:border-violet-600 dark:text-violet-400 dark:hover:bg-violet-900/30 transition-colors">
{{ s }}
</button>
}
</div>
</div>
}
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="newName" [placeholder]="'budgets.placeholder_name' | translate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'budgets.label_amount' | translate }}</label>
<input type="number" [(ngModel)]="newAmount" placeholder="0.00" step="0.01"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'budgets.label_account' | translate }}</label>
<select [(ngModel)]="newAccountId"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="newActive" [(ngModel)]="newActive"
class="h-4 w-4 rounded border-gray-300 bg-gray-50 focus:ring-3 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-violet-600 cursor-pointer" />
<label for="newActive" class="text-sm font-medium text-gray-900 dark:text-white cursor-pointer">{{ 'budgets.label_active' | translate }}</label>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeCreateModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="createBudget()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.create' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- EDIT MODAL -->
@if (showEditModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeEditModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'budgets.edit_entry' | translate }}</h3>
<button type="button" (click)="closeEditModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="editName"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'budgets.label_amount' | translate }}</label>
<input type="number" [(ngModel)]="editAmount" step="0.01"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'budgets.label_category' | translate }}</label>
<select [(ngModel)]="editCategory"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (group of categoryGroups; track group.key) {
<option [value]="group.key">{{ group.label | translate }}</option>
}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'budgets.label_account' | translate }}</label>
<select [(ngModel)]="editAccountId"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="editActive" [(ngModel)]="editActive"
class="h-4 w-4 rounded border-gray-300 bg-gray-50 focus:ring-3 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-violet-600 cursor-pointer" />
<label for="editActive" class="text-sm font-medium text-gray-900 dark:text-white cursor-pointer">{{ 'budgets.label_active' | translate }}</label>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeEditModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="updateBudget()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.save' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- DELETE MODAL -->
@if (showDeleteModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeDeleteModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.delete_confirm_title' | translate }}</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.delete_confirm_text' | translate }}</p>
<div class="flex items-center justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="confirmDelete()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900">
{{ 'common.delete' | translate }}
</button>
</div>
</div>
</div>
</div>
}
+22
View File
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Budgets } from './budgets';
describe('Budgets', () => {
let component: Budgets;
let fixture: ComponentFixture<Budgets>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Budgets],
}).compileComponents();
fixture = TestBed.createComponent(Budgets);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+231
View File
@@ -0,0 +1,231 @@
import { Component, OnInit, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../services/api';
export const CATEGORY_GROUPS = [
{
key: 'fixed_expenses',
label: 'budgets.categories.fixed_expenses',
suggestions: ['Miete / Wohnung', 'Nahrungsmittel', 'Strom'],
},
{
key: 'mobile_internet',
label: 'budgets.categories.mobile_internet',
suggestions: ['Mobile', 'Internet'],
},
{
key: 'subscriptions',
label: 'budgets.categories.subscriptions',
suggestions: ['Netflix', 'Disney+', 'Prime', 'Youtube'],
},
{
key: 'leisure',
label: 'budgets.categories.leisure',
suggestions: [],
},
{
key: 'tax_reserves',
label: 'budgets.categories.tax_reserves',
suggestions: [],
},
{
key: 'insurance',
label: 'budgets.categories.insurance',
suggestions: ['3. Säule', 'Privathaftpflicht'],
},
{
key: 'loans',
label: 'budgets.categories.loans',
suggestions: [],
},
];
@Component({
selector: 'app-budgets',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, TranslateModule],
templateUrl: './budgets.html',
styleUrl: './budgets.css',
})
export class Budgets implements OnInit {
budgets = signal<any[]>([]);
accounts = signal<any[]>([]);
categoryGroups = CATEGORY_GROUPS;
// No Accounts Modal
showNoAccountsModal = signal(false);
// Create Modal
showCreateModal = signal(false);
newName = '';
newAmount = 0;
newCategory = 'fixed_expenses';
newAccountId: number | null = null;
newActive = true;
currentSuggestions: string[] = [];
// Edit Modal
showEditModal = signal(false);
editId = 0;
// Delete Modal
showDeleteModal = signal(false);
deleteTargetId = 0;
editName = '';
editAmount = 0;
editCategory = 'fixed_expenses';
editAccountId: number | null = null;
editActive = true;
constructor(private api: ApiService) {}
ngOnInit(): void {
this.loadBudgets();
this.loadAccounts();
}
loadBudgets() {
this.api.getBudgets().subscribe({
next: (data) => this.budgets.set(data),
error: (err) => console.error('Fehler:', err),
});
}
loadAccounts() {
this.api.getAccounts().subscribe({
next: (data) => {
this.accounts.set(data.filter((a) => a.active));
},
error: (err) => console.error('Fehler:', err),
});
}
budgetsForCategory(categoryKey: string): any[] {
return this.budgets().filter((b) => b.main_category === categoryKey);
}
totalForCategory(categoryKey: string): number {
return this.budgetsForCategory(categoryKey).reduce(
(sum, b) => sum + parseFloat(b.amount),
0
);
}
grandTotal(): number {
return this.budgets().reduce((sum, b) => sum + parseFloat(b.amount), 0);
}
accountName(id: number): string {
return this.accounts().find((a) => a.id === id)?.name ?? '';
}
labelForCategory(key: string): string {
return CATEGORY_GROUPS.find((g) => g.key === key)?.label ?? key;
}
closeNoAccountsModal() {
this.showNoAccountsModal.set(false);
}
// Create
openCreateModal(categoryKey: string) {
if (this.accounts().length === 0) {
this.showNoAccountsModal.set(true);
return;
}
this.newCategory = categoryKey;
this.newName = '';
this.newAmount = 0;
this.newAccountId = this.accounts()[0].id;
this.newActive = true;
this.currentSuggestions =
CATEGORY_GROUPS.find((g) => g.key === categoryKey)?.suggestions ?? [];
this.showCreateModal.set(true);
}
closeCreateModal() {
this.showCreateModal.set(false);
}
applySuggestion(name: string) {
this.newName = name;
}
createBudget() {
if (!this.newName || !this.newAccountId) return;
this.api
.createBudget({
name: this.newName,
amount: this.newAmount,
main_category: this.newCategory,
account: this.newAccountId,
active: this.newActive,
})
.subscribe({
next: () => {
this.loadBudgets();
this.closeCreateModal();
},
error: (err) => console.error('Fehler beim Erstellen:', err),
});
}
// Edit
openEditModal(budget: any) {
this.editId = budget.id;
this.editName = budget.name;
this.editAmount = budget.amount;
this.editCategory = budget.main_category;
this.editAccountId = budget.account;
this.editActive = budget.active;
this.showEditModal.set(true);
}
closeEditModal() {
this.showEditModal.set(false);
}
updateBudget() {
if (!this.editName || !this.editAccountId) return;
this.api
.updateBudget(this.editId, {
name: this.editName,
amount: this.editAmount,
main_category: this.editCategory,
account: this.editAccountId,
active: this.editActive,
})
.subscribe({
next: () => {
this.loadBudgets();
this.closeEditModal();
},
error: (err) => console.error('Fehler beim Bearbeiten:', err),
});
}
// Delete
openDeleteModal(id: number) {
this.deleteTargetId = id;
this.showDeleteModal.set(true);
}
closeDeleteModal() {
this.showDeleteModal.set(false);
this.deleteTargetId = 0;
}
confirmDelete() {
this.api.deleteBudget(this.deleteTargetId).subscribe({
next: () => {
this.loadBudgets();
this.closeDeleteModal();
},
error: (err) => console.error('Error deleting budget:', err),
});
}
}
+328
View File
@@ -0,0 +1,328 @@
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<!-- Year navigation -->
<div class="flex items-center gap-1">
<button (click)="prevYear()"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 8-4 4 4 4"/>
</svg>
</button>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white w-16 text-center">{{ currentYear() }}</h1>
<button (click)="nextYear()"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
<!-- Flowbite: outline/arrows/chevron-right -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m10 16 4-4-4-4"/>
</svg>
</button>
</div>
<!-- Canton selector -->
<select [ngModel]="canton()" (ngModelChange)="onCantonChange($event)"
class="rounded-lg border border-gray-300 bg-gray-50 px-3 py-1.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (c of cantons; track c.code) {
<option [value]="c.code">{{ c.code }} {{ ('canton_names.' + c.code) | translate }}</option>
}
</select>
<!-- Filters -->
<div class="flex items-center gap-2 flex-wrap">
<button (click)="toggleFilter('holidays')"
[class]="filters().holidays ? 'bg-orange-100 text-orange-700 border-orange-300 dark:bg-orange-900/30 dark:text-orange-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
<span class="w-2 h-2 rounded-full bg-orange-400 shrink-0"></span>
{{ 'calendar.filter_holidays' | translate }}
</button>
<button (click)="toggleFilter('school')"
[class]="filters().school ? 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
<span class="w-2 h-2 rounded-full bg-emerald-400 shrink-0"></span>
{{ 'calendar.filter_school' | translate }}
</button>
<button (click)="toggleFilter('expenses')"
[class]="filters().expenses ? 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-900/30 dark:text-violet-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
<span class="w-2 h-2 rounded-full bg-violet-400 shrink-0"></span>
{{ 'calendar.filter_invoices' | translate }}
</button>
<button (click)="toggleFilter('deadlines')"
[class]="filters().deadlines ? 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-white text-gray-500 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600'"
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors">
<span class="w-2 h-2 rounded-full bg-blue-400 shrink-0"></span>
{{ 'calendar.filter_deadlines' | translate }}
</button>
</div>
@if (viewMode() === 'month') {
<button (click)="backToYear()"
class="ml-auto inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors">
<!-- Flowbite: outline/arrows/chevron-left -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 8-4 4 4 4"/>
</svg>
{{ 'calendar.year_view' | translate }}
</button>
}
<!-- iCal Subscribe -->
@if (icalOpen()) {
<div class="fixed inset-0 z-40" (click)="closeIcal()"></div>
}
<div class="relative" [class.ml-auto]="viewMode() !== 'month'">
<button (click)="toggleIcal()"
class="inline-flex items-center gap-1.5 rounded-lg border border-violet-300 px-3 py-1.5 text-sm font-medium text-violet-700 hover:bg-violet-50 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-violet-700 dark:text-violet-400 dark:hover:bg-violet-900/20 transition-colors">
<!-- Flowbite: outline/general/calendar (with date dots) -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 10h16m-8-3V4M7 7V4m10 3V4M5 20h14a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1Zm3-7h.01v.01H8V13Zm4 0h.01v.01H12V13Zm4 0h.01v.01H16V13Zm-8 4h.01v.01H8V17Zm4 0h.01v.01H12V17Zm4 0h.01v.01H16V17Z"/>
</svg>
{{ 'calendar.subscribe' | translate }}
</button>
@if (icalOpen()) {
<div class="absolute right-0 top-10 z-50 w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 flex items-center justify-between">
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ 'calendar.ical_title' | translate }}</p>
<button (click)="closeIcal()"
class="inline-flex items-center rounded-lg bg-transparent p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<p class="mb-3 text-xs text-gray-400 dark:text-gray-500">{{ 'calendar.ical_desc' | translate }}</p>
<div class="mb-3 flex items-center gap-2">
<input [value]="icalUrl()" readonly
class="flex-1 truncate rounded-lg border border-gray-300 bg-gray-50 px-2 py-1.5 text-xs text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300" />
<button (click)="copyICalUrl()"
class="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white transition-colors"
[class]="icalCopied() ? 'bg-green-500' : 'bg-violet-700 hover:bg-violet-800'">
{{ icalCopied() ? ('calendar.ical_copied' | translate) : ('calendar.ical_copy' | translate) }}
</button>
</div>
<div class="flex justify-end">
<button (click)="closeIcal()"
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-900 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{{ 'common.close' | translate }}
</button>
</div>
</div>
}
</div>
</div>
<!-- YEAR VIEW -->
@if (viewMode() === 'year') {
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@for (m of getMonthsData(); track m.month) {
<div (click)="openMonth(m.month)"
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-3 transition-colors hover:border-violet-400 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-violet-500">
<p class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
{{ m.name | translate }}
</p>
<!-- Day headers -->
<div class="grid grid-cols-7 mb-1">
@for (dl of dayLabels; track dl) {
<div class="text-center text-xs font-medium text-gray-400 dark:text-gray-500">{{ dl | translate }}</div>
}
</div>
<!-- Day cells -->
<div class="grid grid-cols-7 gap-px">
@for (cell of m.cells; track $index) {
@if (cell.date) {
<button (click)="selectDay(cell); $event.stopPropagation()"
[class.ring-2]="cell.isToday || selectedDate() === cell.date"
[class.ring-violet-600]="cell.isToday"
[class.ring-violet-400]="!cell.isToday && selectedDate() === cell.date"
[class.bg-violet-50]="cell.isToday"
[class.dark:bg-violet-900]="cell.isToday"
[class.text-violet-700]="cell.isToday && !cell.isWeekend"
[class.text-gray-400]="cell.isWeekend && !cell.isToday"
[class.text-gray-700]="!cell.isWeekend && !cell.isToday"
[class.dark:text-gray-300]="!cell.isToday"
class="relative flex min-h-[28px] flex-col items-center justify-start rounded py-0.5 text-xs transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<span>{{ cell.day }}</span>
@if (cell.events.length > 0) {
<div class="mt-0.5 flex flex-wrap justify-center gap-px">
@for (type of uniqueEventTypes(cell.events); track type) {
<span class="h-1 w-1 rounded-full {{ dotClasses(type) }}"></span>
}
</div>
}
</button>
} @else {
<div></div>
}
}
</div>
</div>
}
</div>
}
<!-- MONTH VIEW -->
@if (viewMode() === 'month') {
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<!-- Month nav -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<button (click)="prevMonth()"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 8-4 4 4 4"/>
</svg>
</button>
<span class="font-semibold text-gray-900 dark:text-white">
{{ monthNames[currentMonth()-1] | translate }} {{ currentYear() }}
</span>
<button (click)="nextMonth()"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m10 16 4-4-4-4"/>
</svg>
</button>
</div>
<div class="p-4">
<!-- Day headers -->
<div class="grid grid-cols-7 mb-2">
@for (dl of dayLabels; track dl) {
<div class="py-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400">{{ dl | translate }}</div>
}
</div>
<!-- Day cells -->
<div class="grid grid-cols-7 gap-1">
@for (cell of buildMonthGrid(currentYear(), currentMonth()); track $index) {
@if (cell.date) {
<button (click)="selectDay(cell)"
[class.ring-2]="cell.isToday || selectedDate() === cell.date"
[class.ring-violet-600]="cell.isToday && selectedDate() !== cell.date"
[class.ring-violet-500]="selectedDate() === cell.date"
[class.bg-violet-50]="cell.isToday && selectedDate() !== cell.date"
[class.bg-violet-100]="selectedDate() === cell.date"
[class.text-violet-700]="cell.isToday && !cell.isWeekend"
[class.text-gray-400]="cell.isWeekend && !cell.isToday"
[class.text-gray-700]="!cell.isWeekend && !cell.isToday"
[class.dark:text-gray-200]="!cell.isToday"
class="min-h-[48px] sm:min-h-[64px] rounded-lg border border-transparent p-1 sm:p-1.5 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<span class="text-sm font-medium">{{ cell.day }}</span>
<div class="mt-1 space-y-0.5">
@for (event of cell.events.slice(0, 3); track $index) {
<div class="truncate rounded px-1 py-0.5 text-xs"
[style.background-color]="event.color + '22'"
[style.color]="event.color">
{{ event.title }}
</div>
}
@if (cell.events.length > 3) {
<div class="text-xs text-gray-400">{{ 'calendar.more_events' | translate: { count: cell.events.length - 3 } }}</div>
}
</div>
</button>
} @else {
<div class="min-h-[48px] sm:min-h-[64px]"></div>
}
}
</div>
</div>
</div>
}
<!-- DAY DETAIL DRAWER -->
@if (selectedDate()) {
<div class="fixed inset-0 z-40 bg-black/30 dark:bg-black/50"
(click)="selectedDate.set(null); showAddDeadline.set(false)">
</div>
}
<div [class]="selectedDate() ? 'translate-x-0' : 'translate-x-full'"
class="fixed right-0 top-0 z-50 flex h-full w-full sm:w-80 flex-col bg-white shadow-2xl transition-transform duration-300 ease-in-out dark:bg-gray-800">
<!-- Drawer Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ selectedDate() }}</h2>
<button (click)="selectedDate.set(null); showAddDeadline.set(false)"
class="inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Drawer Body -->
<div class="flex-1 space-y-2 overflow-y-auto px-5 py-4">
@if (selectedEvents().length === 0 && !showAddDeadline()) {
<p class="text-sm text-gray-400 dark:text-gray-500">{{ 'calendar.no_events' | translate }}</p>
}
@for (event of selectedEvents(); track $index) {
<div class="flex items-start justify-between rounded-lg p-3"
[style.background-color]="event.color + '15'">
<div class="flex items-start gap-3">
<span class="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" [style.background-color]="event.color"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ event.title }}</p>
<p class="text-xs text-gray-500">{{ eventTypeLabel(event.type) | translate }}</p>
@if (event.amount) {
<p class="mt-0.5 text-xs font-semibold" [style.color]="event.color">{{ event.amount | number:'1.2-2' }} CHF</p>
}
</div>
</div>
@if (event.type === 'deadline') {
<button (click)="deleteDeadline(event.id!)"
class="ml-2 shrink-0 text-xs text-red-400 hover:text-red-600 transition-colors">
{{ 'common.delete' | translate }}
</button>
}
</div>
}
<!-- Add Deadline form -->
@if (showAddDeadline()) {
<div class="border-t border-gray-100 pt-4 dark:border-gray-700">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ 'calendar.add_deadline' | translate }}</h3>
<div class="space-y-3">
<input type="text" [(ngModel)]="newDeadlineTitle" [placeholder]="'calendar.label_title' | translate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<input type="date" [(ngModel)]="newDeadlineDate" [attr.lang]="translate.currentLang"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<select [(ngModel)]="newDeadlineType"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="" disabled hidden>{{ 'calendar.select_type' | translate }}</option>
@for (t of deadlineTypes; track t.key) {
<option [value]="t.key">{{ t.label | translate }}</option>
}
</select>
<div class="flex justify-end gap-2">
<button (click)="showAddDeadline.set(false)"
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{{ 'common.cancel' | translate }}
</button>
<button (click)="saveDeadline()"
class="rounded-lg bg-violet-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.save' | translate }}
</button>
</div>
</div>
</div>
}
</div>
<!-- Drawer Footer -->
<div class="border-t border-gray-200 px-5 py-4 dark:border-gray-700">
<button (click)="openAddDeadline()"
class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-violet-700 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
<!-- Flowbite: outline/general/calendar (with date dots) -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 10h16m-8-3V4M7 7V4m10 3V4M5 20h14a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1Zm3-7h.01v.01H8V13Zm4 0h.01v.01H12V13Zm4 0h.01v.01H16V13Zm-8 4h.01v.01H8V17Zm4 0h.01v.01H12V17Zm4 0h.01v.01H16V17Z"/>
</svg>
{{ 'calendar.add_deadline' | translate }}
</button>
</div>
</div>
</div>
+331
View File
@@ -0,0 +1,331 @@
import { Component, OnInit, OnDestroy, effect, signal } from '@angular/core';
import { Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ActivatedRoute } from '@angular/router';
import { ApiService } from '../services/api';
import { HolidaysService } from '../services/holidays';
import { CalendarEvent, CANTONS } from '../data/swiss-holidays';
const MONTH_NAMES = [
'calendar.months.1','calendar.months.2','calendar.months.3','calendar.months.4',
'calendar.months.5','calendar.months.6','calendar.months.7','calendar.months.8',
'calendar.months.9','calendar.months.10','calendar.months.11','calendar.months.12',
];
const DAY_LABELS = [
'calendar.weekdays.0','calendar.weekdays.1','calendar.weekdays.2','calendar.weekdays.3',
'calendar.weekdays.4','calendar.weekdays.5','calendar.weekdays.6',
];
export interface DayCell {
date: string | null; // 'YYYY-MM-DD' or null for padding
day: number | null;
isToday: boolean;
isWeekend: boolean;
events: CalendarEvent[];
}
@Component({
selector: 'app-calendar',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './calendar.html',
})
export class Calendar implements OnInit, OnDestroy {
readonly cantons = CANTONS;
readonly monthNames = MONTH_NAMES;
readonly dayLabels = DAY_LABELS;
currentYear = signal(new Date().getFullYear());
viewMode = signal<'year' | 'month'>('year');
currentMonth = signal(1);
selectedDate = signal<string | null>(null);
canton = signal('ZH');
filters = signal({ holidays: true, school: true, expenses: true, deadlines: true });
holidays = signal<CalendarEvent[]>([]);
schoolHolidays = signal<CalendarEvent[]>([]);
currentLang = signal(typeof window !== 'undefined' ? (localStorage.getItem('lang') ?? 'de') : 'de');
private langSub?: Subscription;
expenses = signal<any[]>([]);
deadlines = signal<any[]>([]);
// Add deadline form
showAddDeadline = signal(false);
newDeadlineTitle = '';
newDeadlineDate = '';
newDeadlineType = 'other';
newDeadlineNotes = '';
// iCal
icalUrl = signal<string | null>(null);
icalOpen = signal(false);
icalCopied = signal(false);
toggleIcal() {
if (!this.icalUrl()) {
this.api.getICalUrl().subscribe({ next: (r) => {
this.icalUrl.set(r.url);
this.icalOpen.set(true);
}});
} else {
this.icalOpen.update(v => !v);
}
}
closeIcal() { this.icalOpen.set(false); }
copyICalUrl() {
const url = this.icalUrl();
if (!url) return;
navigator.clipboard.writeText(url).then(() => {
this.icalCopied.set(true);
setTimeout(() => this.icalCopied.set(false), 2000);
});
}
// Edit deadline
editDeadlineId: number | null = null;
showEditDeadline = signal(false);
editDeadlineTitle = '';
editDeadlineDate = '';
editDeadlineType = 'other';
deadlineTypes = [
{ key: 'tax', label: 'calendar.deadline_types.tax' },
{ key: 'insurance', label: 'calendar.deadline_types.insurance' },
{ key: 'invoice', label: 'calendar.deadline_types.invoice' },
{ key: 'personal', label: 'calendar.deadline_types.personal' },
{ key: 'other', label: 'calendar.deadline_types.other' },
];
readonly today = new Date().toISOString().split('T')[0];
constructor(
private api: ApiService,
private route: ActivatedRoute,
private holidaysService: HolidaysService,
public translate: TranslateService,
) {
effect(() => {
this.loadHolidays(this.currentYear(), this.canton(), this.currentLang());
});
}
ngOnDestroy(): void {
this.langSub?.unsubscribe();
}
private loadHolidays(year: number, canton: string, lang: string): void {
this.holidays.set([]);
this.schoolHolidays.set([]);
this.holidaysService.getPublicHolidays(year, canton, lang).subscribe(events => this.holidays.set(events));
this.holidaysService.getSchoolHolidays(year, canton, lang).subscribe(events => this.schoolHolidays.set(events));
}
ngOnInit(): void {
this.api.getProfile().subscribe({ next: (p) => { if (p.canton) this.canton.set(p.canton); } });
this.api.getExpenses().subscribe({ next: (d) => this.expenses.set(d) });
this.api.getDeadlines().subscribe({ next: (d) => this.deadlines.set(d) });
this.langSub = this.translate.onLangChange.subscribe(e => this.currentLang.set(e.lang));
this.route.queryParams.subscribe(params => {
if (params['year']) this.currentYear.set(+params['year']);
if (params['month']) {
this.currentMonth.set(+params['month']);
this.viewMode.set('month');
}
});
}
// ── Event Map ────────────────────────────────────────────────────────────────
private buildEventMap(year: number): Map<string, CalendarEvent[]> {
const map = new Map<string, CalendarEvent[]>();
const add = (e: CalendarEvent) => {
if (!map.has(e.date)) map.set(e.date, []);
map.get(e.date)!.push(e);
};
const f = this.filters();
if (f.holidays) {
this.holidays().forEach(add);
}
if (f.school) {
this.schoolHolidays().forEach(add);
}
if (f.expenses) {
this.expenses()
.filter((e) => e.due_date && e.due_date.startsWith(String(year)))
.forEach((e) => add({ date: e.due_date, title: e.name, type: 'expense', amount: parseFloat(e.amount), color: '#8b5cf6', id: e.id }));
}
if (f.deadlines) {
this.deadlines()
.filter((d) => d.date.startsWith(String(year)))
.forEach((d) => add({ date: d.date, title: d.title, type: 'deadline', color: '#3b82f6', id: d.id }));
}
return map;
}
// ── Month Grid ───────────────────────────────────────────────────────────────
buildMonthGrid(year: number, month: number): DayCell[] {
const map = this.buildEventMap(year);
const cells: DayCell[] = [];
const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun
const offset = (firstDay + 6) % 7; // Monday-based offset
const daysInMonth = new Date(year, month, 0).getDate();
for (let i = 0; i < offset; i++) cells.push({ date: null, day: null, isToday: false, isWeekend: false, events: [] });
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const dow = (offset + d - 1) % 7; // 0=Mon, 5=Sat, 6=Sun
cells.push({
date: dateStr,
day: d,
isToday: dateStr === this.today,
isWeekend: dow >= 5,
events: map.get(dateStr) ?? [],
});
}
return cells;
}
getMonthsData(): { month: number; name: string; cells: DayCell[] }[] {
return Array.from({ length: 12 }, (_, i) => ({
month: i + 1,
name: MONTH_NAMES[i],
cells: this.buildMonthGrid(this.currentYear(), i + 1),
}));
}
// ── Navigation ───────────────────────────────────────────────────────────────
prevYear() { this.currentYear.update((y) => y - 1); this.selectedDate.set(null); }
nextYear() { this.currentYear.update((y) => y + 1); this.selectedDate.set(null); }
openMonth(month: number) {
this.currentMonth.set(month);
this.viewMode.set('month');
this.selectedDate.set(null);
}
backToYear() {
this.viewMode.set('year');
this.selectedDate.set(null);
}
prevMonth() {
if (this.currentMonth() === 1) { this.currentMonth.set(12); this.currentYear.update((y) => y - 1); }
else this.currentMonth.update((m) => m - 1);
this.selectedDate.set(null);
}
nextMonth() {
if (this.currentMonth() === 12) { this.currentMonth.set(1); this.currentYear.update((y) => y + 1); }
else this.currentMonth.update((m) => m + 1);
this.selectedDate.set(null);
}
// ── Day selection ─────────────────────────────────────────────────────────────
selectDay(cell: DayCell) {
if (!cell.date) return;
this.selectedDate.set(cell.date === this.selectedDate() ? null : cell.date);
this.showAddDeadline.set(false);
}
selectedEvents(): CalendarEvent[] {
const d = this.selectedDate();
if (!d) return [];
const map = this.buildEventMap(this.currentYear());
return map.get(d) ?? [];
}
// ── Filter & Canton ───────────────────────────────────────────────────────────
toggleFilter(key: keyof ReturnType<typeof this.filters>) {
this.filters.update((f) => ({ ...f, [key]: !f[key] }));
}
onCantonChange(code: string) {
this.canton.set(code);
this.api.updateProfile({ canton: code }).subscribe();
}
// ── Deadline CRUD ─────────────────────────────────────────────────────────────
openAddDeadline() {
this.newDeadlineTitle = '';
this.newDeadlineDate = this.selectedDate() ?? '';
this.newDeadlineType = '';
this.newDeadlineNotes = '';
this.showAddDeadline.set(true);
}
saveDeadline() {
if (!this.newDeadlineTitle || !this.newDeadlineDate) return;
this.api.createDeadline({
title: this.newDeadlineTitle,
date: this.newDeadlineDate,
type: this.newDeadlineType || 'other',
notes: this.newDeadlineNotes,
}).subscribe({ next: (d) => { this.deadlines.update((dl) => [...dl, d]); this.showAddDeadline.set(false); } });
}
openEditDeadline(event: CalendarEvent) {
this.editDeadlineId = event.id!;
this.editDeadlineTitle = event.title;
this.editDeadlineDate = event.date;
this.editDeadlineType = 'other';
this.showEditDeadline.set(true);
}
updateDeadline() {
if (!this.editDeadlineId) return;
this.api.updateDeadline(this.editDeadlineId, {
title: this.editDeadlineTitle,
date: this.editDeadlineDate,
type: this.editDeadlineType,
}).subscribe({ next: (updated) => {
this.deadlines.update((dl) => dl.map((d) => d.id === this.editDeadlineId ? updated : d));
this.showEditDeadline.set(false);
}});
}
deleteDeadline(id: number) {
this.api.deleteDeadline(id).subscribe({ next: () => {
this.deadlines.update((dl) => dl.filter((d) => d.id !== id));
}});
}
// ── Helpers ───────────────────────────────────────────────────────────────────
eventTypeLabel(type: string): string {
const keys: Record<string, string> = {
national: 'calendar.filter_holidays',
canton: 'calendar.filter_holidays',
school: 'calendar.filter_school',
deadline: 'calendar.filter_deadlines',
expense: 'calendar.filter_invoices',
};
return keys[type] ?? type;
}
dotClasses(type: string): string {
const colors: Record<string, string> = {
national: 'bg-orange-400', canton: 'bg-red-400',
school: 'bg-emerald-400', deadline: 'bg-blue-400', expense: 'bg-violet-400',
};
return colors[type] ?? 'bg-gray-400';
}
uniqueEventTypes(events: CalendarEvent[]): string[] {
return [...new Set(events.map((e) => e.type))];
}
}
+246
View File
@@ -0,0 +1,246 @@
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-light text-gray-900 dark:text-white">{{ greeting() }}</h1>
<p class="text-base text-gray-500 dark:text-gray-400 mt-1">{{ dateTimeDisplay() }}</p>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-2 gap-4 mb-6 lg:grid-cols-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.total_income' | translate }}</p>
<p class="mt-1 text-xl sm:text-2xl font-bold text-emerald-600">{{ totalIncome() | number:'1.2-2' }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.per_month' | translate }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.fixed_costs' | translate }}</p>
<p class="mt-1 text-xl sm:text-2xl font-bold text-violet-600">{{ totalFixedCosts() | number:'1.2-2' }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.per_month' | translate }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.expenses' | translate }}</p>
<p class="mt-1 text-xl sm:text-2xl font-bold text-red-500">{{ totalExpenses() | number:'1.2-2' }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.chf_total' | translate }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 sm:p-5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'dashboard.balance' | translate }}</p>
<p class="mt-1 text-xl sm:text-2xl font-bold"
[class.text-emerald-600]="balance() >= 0"
[class.text-red-500]="balance() < 0">
{{ balance() | number:'1.2-2' }}
</p>
<p class="text-xs text-gray-400 mt-0.5">{{ 'dashboard.chf_remaining' | translate }}</p>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 gap-4 mb-6 lg:grid-cols-3">
<!-- Bar Chart: Income vs Expenses -->
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 md:p-6">
<!-- Header -->
<div class="flex justify-between pb-4 mb-4 border-b border-gray-100 dark:border-gray-700">
<div class="flex items-center min-w-0">
<div class="w-12 h-12 bg-violet-50 dark:bg-violet-900/30 border border-violet-200 dark:border-violet-800 flex items-center justify-center rounded-full me-3 flex-shrink-0">
<svg class="w-7 h-7 text-violet-600 dark:text-violet-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15v4m6-6v6m6-4v4m6-6v6M3 11l6-5 6 5 5.5-5.5"/>
</svg>
</div>
<div class="min-w-0">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'dashboard.income_vs_expenses' | translate: { year: selectedYear() } }}</h2>
</div>
</div>
</div>
<!-- Chart -->
<div id="bar-chart"></div>
<!-- Footer -->
<div class="border-t border-gray-100 dark:border-gray-700">
<div class="flex justify-between items-center pt-4 md:pt-6">
<!-- Year dropdown -->
<div class="relative">
<button (click)="toggleYearDropdown()" type="button"
class="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white inline-flex items-center">
{{ selectedYear() }}
<svg class="w-4 h-4 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
</svg>
</button>
@if (yearDropdownOpen()) {
<div class="absolute bottom-full mb-1 z-10 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg w-32">
<ul class="p-2 text-sm text-gray-700 dark:text-gray-300 font-medium">
@for (year of availableYears(); track year) {
<li>
<button (click)="selectYear(year)" type="button"
class="w-full text-left p-2 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded">
{{ year }}
</button>
</li>
}
</ul>
</div>
}
</div>
</div>
</div>
</div>
<!-- Pie Chart: Fixed Costs Breakdown -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 md:p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white min-w-0 me-2">{{ 'dashboard.fixed_costs_breakdown' | translate }}</h2>
<button (click)="toggleDonut()" type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/30 transition-colors">
@if (donutExpanded()) {
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/>
</svg>
} @else {
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
}
</button>
</div>
<!-- Pie Chart -->
<div id="donut-chart" [class.hidden]="donutExpanded()"></div>
<!-- List View -->
<div class="flex flex-col gap-2.5 overflow-y-auto max-h-80"
[class.hidden]="!donutExpanded()">
@for (item of donutItems(); track item.name) {
<div class="flex items-center gap-3">
<span class="flex-1 text-sm text-gray-700 dark:text-gray-200 truncate">{{ item.name }}</span>
<span class="text-sm text-gray-900 dark:text-white whitespace-nowrap">CHF {{ item.amount | number:'1.2-2' }}</span>
<span class="text-xs text-gray-400 w-10 text-right">{{ item.pct }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Bottom Row -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- Savings Rate -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 md:p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'dashboard.savings_rate' | translate }}</h2>
<button (click)="toggleSavingsSettings()" type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/30 transition-colors">
@if (savingsSettingsOpen()) {
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/>
</svg>
} @else {
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m7.171 12.906-2.153 6.411 2.672-.89 1.568 2.34 1.825-5.183m5.73-2.678 2.154 6.411-2.673-.89-1.568 2.34-1.825-5.183M9.165 4.3c.58.068 1.153-.17 1.515-.628a1.681 1.681 0 0 1 2.64 0 1.68 1.68 0 0 0 1.515.628 1.681 1.681 0 0 1 1.866 1.866c-.068.58.17 1.154.628 1.516a1.681 1.681 0 0 1 0 2.639 1.682 1.682 0 0 0-.628 1.515 1.681 1.681 0 0 1-1.866 1.866 1.681 1.681 0 0 0-1.516.628 1.681 1.681 0 0 1-2.639 0 1.681 1.681 0 0 0-1.515-.628 1.681 1.681 0 0 1-1.867-1.866 1.681 1.681 0 0 0-.627-1.515 1.681 1.681 0 0 1 0-2.64c.458-.361.696-.935.627-1.515A1.681 1.681 0 0 1 9.165 4.3ZM14 9a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg>
}
</button>
</div>
<!-- Hauptansicht -->
@if (!savingsSettingsOpen()) {
<div class="flex items-end gap-2 mb-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ savingsRate() }}%</span>
<span class="text-sm text-gray-400 mb-1">{{ 'dashboard.of_income' | translate }}</span>
</div>
<div class="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-3 relative">
<div class="h-3 rounded-full transition-all duration-500"
[class]="savingsRateColor()"
[style.width.%]="savingsRate()">
</div>
<div class="absolute -top-1 h-5 w-0.5 bg-violet-600 dark:bg-violet-400 rounded-full -translate-x-1/2 transition-all duration-300"
[style.left.%]="savingsGoal()">
</div>
</div>
<div class="flex justify-between mt-3 text-xs text-gray-400">
<span>0%</span>
<span class="text-violet-600 dark:text-violet-400 font-medium">{{ 'dashboard.goal' | translate }}: {{ savingsGoal() }}%</span>
<span>100%</span>
</div>
<!-- Einstellungsansicht -->
} @else {
<div class="flex flex-col gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ 'dashboard.goal' | translate }} (%)
</label>
<div class="flex items-center gap-3">
<input type="number"
[value]="goalInputValue()"
(input)="goalInputValue.set(+$any($event.target).value)"
(keyup.enter)="saveGoal()"
min="1" max="100"
class="w-24 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 px-3 py-2 focus:outline-none">
<span class="text-sm text-gray-500 dark:text-gray-400">%</span>
</div>
<p class="mt-2 text-xs text-gray-400">{{ 'dashboard.goal_hint' | translate }}</p>
</div>
<!-- Preview der neuen Zielposition -->
<div class="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-3 relative">
<div class="absolute -top-1 h-5 w-0.5 bg-violet-600 dark:bg-violet-400 rounded-full -translate-x-1/2 transition-all duration-300"
[style.left.%]="goalInputValue()">
</div>
</div>
<div class="flex justify-between text-xs text-gray-400">
<span>0%</span>
<span class="text-violet-600 dark:text-violet-400 font-medium">{{ goalInputValue() }}%</span>
<span>100%</span>
</div>
<div class="flex gap-2 pt-1">
<button (click)="saveGoal()" type="button"
class="flex-1 text-white bg-violet-600 hover:bg-violet-700 focus:ring-4 focus:ring-violet-300 dark:focus:ring-violet-800 font-medium rounded-lg text-sm px-4 py-2 focus:outline-none transition-colors">
{{ 'common.save' | translate }}
</button>
<button (click)="toggleSavingsSettings()" type="button"
class="flex-1 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 font-medium rounded-lg text-sm px-4 py-2 focus:outline-none transition-colors">
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
}
</div>
<!-- Recent Expenses -->
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-base font-medium text-gray-900 dark:text-white mb-4">{{ 'dashboard.recent_expenses' | translate }}</h2>
@if (recentExpenses().length === 0) {
<p class="text-sm text-gray-400">{{ 'dashboard.no_expenses' | translate }}</p>
} @else {
<div class="divide-y divide-gray-100 dark:divide-gray-700">
@for (expense of recentExpenses(); track expense.id) {
<div class="flex items-center justify-between py-2.5">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-white">{{ expense.name }}</p>
<p class="text-xs text-gray-400">{{ expense.date | date:'dd.MM.yyyy' }} · {{ ('expenses.categories.' + expense.category) | translate }}</p>
</div>
<span class="text-sm font-semibold text-red-500">-{{ expense.amount | number:'1.2-2' }} CHF</span>
</div>
}
</div>
}
</div>
</div>
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Dashboard } from './dashboard';
describe('Dashboard', () => {
let component: Dashboard;
let fixture: ComponentFixture<Dashboard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Dashboard],
}).compileComponents();
fixture = TestBed.createComponent(Dashboard);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+358
View File
@@ -0,0 +1,358 @@
import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ApiService } from '../services/api';
import ApexCharts from 'apexcharts';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, TranslateModule],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css',
})
export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
accounts = signal<any[]>([]);
budgets = signal<any[]>([]);
expenses = signal<any[]>([]);
transactions = signal<any[]>([]);
donutExpanded = signal(false);
selectedYear = signal(new Date().getFullYear());
yearDropdownOpen = signal(false);
savingsGoal = signal(20);
savingsSettingsOpen = signal(false);
goalInputValue = signal(20);
greeting = signal('');
dateTimeDisplay = signal('');
private firstName = '';
private readonly donutColors = ['#7c3aed', '#a78bfa', '#5b21b6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444'];
private barChart?: ApexCharts;
private donutChart?: ApexCharts;
private dataLoaded = 0;
private readonly totalRequests = 4;
private timeInterval?: ReturnType<typeof setInterval>;
private langSub?: Subscription;
constructor(private api: ApiService, private translate: TranslateService) {}
ngOnInit(): void {
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } });
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } });
this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } });
this.api.getProfile().subscribe({
next: (p) => {
this.firstName = p.first_name || '';
const goal = p.savings_rate_goal ?? 20;
this.savingsGoal.set(goal);
this.goalInputValue.set(goal);
this.updateHeader();
},
});
this.updateHeader();
this.timeInterval = setInterval(() => this.updateHeader(), 30000);
this.langSub = this.translate.onLangChange.subscribe(() => this.updateHeader());
}
ngAfterViewInit(): void {}
ngOnDestroy(): void {
this.barChart?.destroy();
this.donutChart?.destroy();
clearInterval(this.timeInterval);
this.langSub?.unsubscribe();
}
private onDataLoaded(): void {
this.dataLoaded++;
if (this.dataLoaded === this.totalRequests) {
setTimeout(() => this.renderCharts(), 50);
}
}
private getLocale(): string {
const lang = this.translate.currentLang || 'de';
return ({ de: 'de-CH', fr: 'fr-CH', it: 'it-CH', en: 'en-GB' } as Record<string, string>)[lang] ?? 'de-CH';
}
private updateHeader(): void {
const now = new Date();
const hour = now.getHours();
const locale = this.getLocale();
let key: string;
if (hour >= 5 && hour < 12) key = 'dashboard.greeting_morning';
else if (hour >= 12 && hour < 18) key = 'dashboard.greeting_afternoon';
else if (hour >= 18 && hour < 22) key = 'dashboard.greeting_evening';
else key = 'dashboard.greeting_night';
const greet = this.translate.instant(key);
this.greeting.set(this.firstName ? `${greet} ${this.firstName}` : greet);
const weekday = now.toLocaleDateString(locale, { weekday: 'long' });
const date = now.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' });
const time = now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`);
}
// KPIs
totalIncome(): number {
return this.accounts()
.filter((a) => a.account_type === 'revenue')
.reduce((sum, a) => sum + parseFloat(a.balance), 0);
}
totalFixedCosts(): number {
return this.budgets()
.filter((b) => b.active)
.reduce((sum, b) => sum + parseFloat(b.amount), 0);
}
totalExpenses(): number {
return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0);
}
balance(): number {
return this.totalIncome() - this.totalFixedCosts() - this.totalExpenses();
}
savingsRate(): number {
const income = this.totalIncome();
if (income === 0) return 0;
return Math.max(0, Math.round((this.balance() / income) * 100));
}
savingsRateColor(): string {
const rate = this.savingsRate();
const goal = this.savingsGoal();
if (rate >= goal) return 'bg-emerald-500';
if (rate >= goal / 2) return 'bg-yellow-400';
return 'bg-red-500';
}
toggleSavingsSettings(): void {
this.goalInputValue.set(this.savingsGoal());
this.savingsSettingsOpen.set(!this.savingsSettingsOpen());
}
saveGoal(): void {
const val = Math.min(100, Math.max(1, this.goalInputValue()));
this.api.updateProfile({ savings_rate_goal: val }).subscribe({
next: () => {
this.savingsGoal.set(val);
this.savingsSettingsOpen.set(false);
},
});
}
toggleDonut(): void {
this.donutExpanded.set(!this.donutExpanded());
}
toggleYearDropdown(): void {
this.yearDropdownOpen.set(!this.yearDropdownOpen());
}
selectYear(year: number): void {
this.selectedYear.set(year);
this.yearDropdownOpen.set(false);
this.renderBarChart();
}
availableYears(): number[] {
const years = new Set<number>([new Date().getFullYear()]);
this.expenses().forEach(e => years.add(new Date(e.date).getFullYear()));
return Array.from(years).sort((a, b) => b - a);
}
donutItems(): { name: string; amount: number; pct: string; color: string }[] {
const active = this.budgets().filter((b) => b.active);
const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0);
return active.map((b, i) => ({
name: b.name,
amount: parseFloat(b.amount),
pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0',
color: this.donutColors[i % this.donutColors.length],
}));
}
recentExpenses(): any[] {
return [...this.expenses()]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 5);
}
// Charts
private renderCharts(): void {
this.renderBarChart();
this.renderDonutChart();
}
private renderBarChart(): void {
const el = document.getElementById('bar-chart');
if (!el) return;
const year = this.selectedYear();
const months = Array.from({ length: 12 }, (_, i) =>
`${year}-${String(i + 1).padStart(2, '0')}`
);
const income = this.totalIncome();
const fixedCosts = this.totalFixedCosts();
const incomeData = months.map(() => +income.toFixed(2));
const fixedData = months.map(() => +fixedCosts.toFixed(2));
const variableData = months.map((m) =>
+this.expenses()
.filter((e) => e.date.startsWith(m))
.reduce((sum, e) => sum + parseFloat(e.amount), 0)
.toFixed(2)
);
const locale = this.getLocale();
const labels = months.map((m) => {
const [y, month] = m.split('-');
return new Date(+y, +month - 1).toLocaleString(locale, { month: 'short' });
});
const fullLabels = months.map((m) => {
const [y, month] = m.split('-');
return new Date(+y, +month - 1).toLocaleString(locale, { month: 'long', year: 'numeric' });
});
const brandColor = '#7c3aed';
const t = (k: string) => this.translate.instant(k);
const options = {
series: [
{ name: t('dashboard.series_income'), color: '#10b981', data: incomeData },
{ name: t('dashboard.series_fixed_costs'), color: brandColor, data: fixedData },
{ name: t('dashboard.series_expenses'), color: '#f97316', data: variableData },
],
chart: {
type: 'bar',
height: '240px',
fontFamily: 'Roboto, sans-serif',
toolbar: { show: false },
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '70%',
borderRadiusApplication: 'end',
borderRadius: 8,
},
},
tooltip: {
shared: true,
intersect: false,
custom: ({ series, dataPointIndex, w }: any) => {
const month = fullLabels[dataPointIndex] ?? w.globals.labels[dataPointIndex];
const rows = (w.globals.seriesNames as string[]).map((name: string, i: number) => {
const val = (series[i][dataPointIndex] as number).toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `<div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
<span style="width:10px;height:10px;border-radius:50%;background:${w.globals.colors[i]};display:inline-block;flex-shrink:0;"></span>
<span>${name}: <strong>CHF ${val}</strong></span>
</div>`;
}).join('');
return `<div style="background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:10px 14px;font-family:Roboto,sans-serif;font-size:13px;color:#111827;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="font-weight:600;margin-bottom:6px;">${month}</div>
${rows}
</div>`;
},
},
states: {
hover: { filter: { type: 'darken', value: 1 } },
},
stroke: { show: true, width: 0, colors: ['transparent'] },
grid: { show: false, padding: { left: 2, right: 2, top: -14 } },
dataLabels: { enabled: false },
legend: { show: false },
xaxis: {
floating: false,
categories: labels,
labels: {
show: true,
style: { fontFamily: 'Roboto, sans-serif', cssClass: 'text-xs font-normal fill-gray-500 dark:fill-gray-400' },
},
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { show: false },
fill: { opacity: 1 },
responsive: [{
breakpoint: 640,
options: {
chart: { height: '200px' },
xaxis: { labels: { rotate: -45, style: { fontSize: '10px' } } },
plotOptions: { bar: { columnWidth: '85%', borderRadius: 5 } },
},
}],
};
this.barChart?.destroy();
this.barChart = new ApexCharts(el, options);
this.barChart.render();
}
private renderDonutChart(): void {
const el = document.getElementById('donut-chart');
if (!el) return;
const active = this.budgets().filter((b) => b.active);
const labels = active.map((b) => b.name);
const series = active.map((b) => parseFloat(b.amount));
if (series.length === 0) return;
const neutralBg = '#ffffff';
const colors = this.donutColors.slice(0, series.length);
const options = {
series,
labels,
chart: {
type: 'pie',
height: 320,
width: '100%',
toolbar: { show: false },
fontFamily: 'Roboto, sans-serif',
},
colors,
stroke: { colors: [neutralBg] },
plotOptions: {
pie: {
labels: { show: true },
size: '100%',
dataLabels: { offset: -25 },
},
},
dataLabels: {
enabled: true,
style: { fontFamily: 'Roboto, sans-serif', fontSize: '12px', fontWeight: '600' },
formatter: (val: number) => `${val.toFixed(1)}%`,
},
legend: { show: false },
tooltip: {
custom: ({ series, seriesIndex, w }: any) => {
const label = w.globals.labels[seriesIndex];
const value = (series[seriesIndex] as number).toFixed(2);
const total = (series as number[]).reduce((a, b) => a + b, 0);
const pct = total > 0 ? ((series[seriesIndex] / total) * 100).toFixed(1) : '0';
return `<div style="background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:10px 14px;font-family:Roboto,sans-serif;font-size:13px;color:#111827;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="font-weight:600;margin-bottom:4px;">${label}</div>
<div>CHF ${value}</div>
<div style="color:#6b7280;">${pct}%</div>
</div>`;
},
},
};
this.donutChart?.destroy();
this.donutChart = new ApexCharts(el, options);
this.donutChart.render();
}
}
+177
View File
@@ -0,0 +1,177 @@
export interface Canton {
code: string;
name: string;
}
export const CANTONS: Canton[] = [
{ code: 'AG', name: 'Aargau' }, { code: 'AI', name: 'Appenzell Innerrhoden' },
{ code: 'AR', name: 'Appenzell Ausserrhoden' }, { code: 'BE', name: 'Bern' },
{ code: 'BL', name: 'Basel-Landschaft' }, { code: 'BS', name: 'Basel-Stadt' },
{ code: 'FR', name: 'Fribourg' }, { code: 'GE', name: 'Geneva' },
{ code: 'GL', name: 'Glarus' }, { code: 'GR', name: 'Graubünden' },
{ code: 'JU', name: 'Jura' }, { code: 'LU', name: 'Lucerne' },
{ code: 'NE', name: 'Neuchâtel' }, { code: 'NW', name: 'Nidwalden' },
{ code: 'OW', name: 'Obwalden' }, { code: 'SG', name: 'St. Gallen' },
{ code: 'SH', name: 'Schaffhausen' }, { code: 'SO', name: 'Solothurn' },
{ code: 'SZ', name: 'Schwyz' }, { code: 'TG', name: 'Thurgau' },
{ code: 'TI', name: 'Ticino' }, { code: 'UR', name: 'Uri' },
{ code: 'VD', name: 'Vaud' }, { code: 'VS', name: 'Valais' },
{ code: 'ZG', name: 'Zug' }, { code: 'ZH', name: 'Zürich' },
];
interface HolidayDef {
name: string;
month?: number;
day?: number;
easterOffset?: number;
cantons: string[];
}
// Easter Sunday dates (Gregorian) — pre-computed for 20242030
const EASTER: Record<number, [number, number]> = {
2024: [3, 31], 2025: [4, 20], 2026: [4, 5],
2027: [3, 28], 2028: [4, 16], 2029: [4, 1], 2030: [4, 21],
};
const HOLIDAY_DEFS: HolidayDef[] = [
{ name: 'New Year', month: 1, day: 1, cantons: ['ALL'] },
{ name: 'Berchtoldstag', month: 1, day: 2, cantons: ['ZH', 'BE', 'LU', 'OW', 'GL', 'ZG', 'FR', 'SO', 'SH', 'TG', 'VD', 'NE', 'GE'] },
{ name: 'Heilige Drei Könige', month: 1, day: 6, cantons: ['UR', 'SZ', 'GR', 'TI', 'VS'] },
{ name: 'Josefstag', month: 3, day: 19, cantons: ['UR', 'SZ', 'NW', 'ZG', 'GR', 'TI', 'VS', 'LU'] },
{ name: 'Good Friday', easterOffset: -2, cantons: ['ZH', 'BE', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'ZG', 'FR', 'SO', 'BS', 'BL', 'SH', 'AR', 'AI', 'SG', 'GR', 'AG', 'TG', 'NE', 'GE', 'JU'] },
{ name: 'Easter Sunday', easterOffset: 0, cantons: ['ALL'] },
{ name: 'Easter Monday', easterOffset: 1, cantons: ['ALL'] },
{ name: 'Tag der Arbeit', month: 5, day: 1, cantons: ['ZH', 'BS', 'BL', 'SH', 'TG', 'TI', 'NE', 'JU', 'SO', 'AG', 'GR'] },
{ name: 'Ascension Day', easterOffset: 39, cantons: ['ALL'] },
{ name: 'Whit Sunday', easterOffset: 49, cantons: ['ALL'] },
{ name: 'Whit Monday', easterOffset: 50, cantons: ['ALL'] },
{ name: 'Corpus Christi', easterOffset: 60, cantons: ['LU', 'UR', 'SZ', 'OW', 'NW', 'ZG', 'FR', 'SO', 'AI', 'AG', 'TI', 'VS', 'NE', 'JU'] },
{ name: 'National Day', month: 8, day: 1, cantons: ['ALL'] },
{ name: 'Mariä Himmelfahrt', month: 8, day: 15, cantons: ['UR', 'SZ', 'OW', 'NW', 'ZG', 'FR', 'AI', 'AG', 'TI', 'VS', 'JU'] },
{ name: 'All Saints', month: 11, day: 1, cantons: ['UR', 'SZ', 'OW', 'NW', 'GL', 'ZG', 'FR', 'SO', 'AI', 'SG', 'GR', 'AG', 'TI', 'VS', 'NE', 'JU'] },
{ name: 'Mariä Empfängnis', month: 12, day: 8, cantons: ['UR', 'SZ', 'OW', 'NW', 'ZG', 'FR', 'AI', 'AG', 'TI', 'VS', 'JU'] },
{ name: 'Christmas', month: 12, day: 25, cantons: ['ALL'] },
{ name: "St. Stephen's Day", month: 12, day: 26, cantons: ['ZH', 'BE', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'ZG', 'BS', 'BL', 'SH', 'AR', 'AI', 'SG', 'GR', 'AG', 'TG', 'TI', 'VS', 'NE'] },
{ name: 'Restauration de Genève', month: 12, day: 12, cantons: ['GE'] },
];
// School holiday ranges per canton. Format: { name, start: [month,day], end: [month,day] }
// Fallback data (used when OpenHolidays API is unavailable). Approximate for most cantons.
interface SchoolHolidayDef {
name: string;
start: [number, number]; // [month, day]
end: [number, number];
cantons: string[];
yearOffset?: number; // end year = start year + yearOffset (default 0)
}
const SCHOOL_HOLIDAYS_BY_YEAR: Record<number, SchoolHolidayDef[]> = {
2025: [
{ name: 'Winter Holidays', start: [2, 10], end: [2, 21], cantons: ['ZH', 'AG', 'TG', 'SH', 'ZG'] },
{ name: 'Winter Holidays', start: [2, 3], end: [2, 14], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE'] },
{ name: 'Winter Holidays', start: [2, 17], end: [2, 28], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS', 'JU'] },
{ name: 'Spring Holidays', start: [4, 14], end: [4, 25], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG'] },
{ name: 'Spring Holidays', start: [4, 7], end: [4, 18], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU'] },
{ name: 'Spring Holidays', start: [4, 28], end: [5, 9], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS'] },
{ name: 'Summer Holidays', start: [7, 14], end: [8, 15], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'] },
{ name: 'Summer Holidays', start: [7, 7], end: [8, 8], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS'] },
{ name: 'Summer Holidays', start: [7, 21], end: [8, 22], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] },
{ name: 'Autumn Holidays', start: [10, 6], end: [10, 17], cantons: ['ZH', 'AG', 'SH', 'ZG', 'TG', 'GR', 'AR', 'AI', 'SG'] },
{ name: 'Autumn Holidays', start: [10, 13], end: [10, 24], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] },
{ name: 'Christmas Holidays', start: [12, 22], end: [1, 2], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'], yearOffset: 1 },
{ name: 'Christmas Holidays', start: [12, 24], end: [1, 9], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'], yearOffset: 1 },
],
2026: [
{ name: 'Winter Holidays', start: [2, 9], end: [2, 20], cantons: ['ZH', 'AG', 'TG', 'SH', 'ZG'] },
{ name: 'Winter Holidays', start: [2, 2], end: [2, 13], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE'] },
{ name: 'Winter Holidays', start: [2, 16], end: [2, 27], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS', 'JU'] },
{ name: 'Spring Holidays', start: [4, 20], end: [5, 2], cantons: ['ZH'] },
{ name: 'Spring Holidays', start: [4, 13], end: [4, 24], cantons: ['AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG'] },
{ name: 'Spring Holidays', start: [4, 6], end: [4, 17], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU'] },
{ name: 'Spring Holidays', start: [4, 27], end: [5, 8], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS'] },
{ name: 'Summer Holidays', start: [7, 13], end: [8, 14], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'] },
{ name: 'Summer Holidays', start: [7, 6], end: [8, 7], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS'] },
{ name: 'Summer Holidays', start: [7, 20], end: [8, 21], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] },
{ name: 'Autumn Holidays', start: [10, 5], end: [10, 16], cantons: ['ZH', 'AG', 'SH', 'ZG', 'TG', 'GR', 'AR', 'AI', 'SG'] },
{ name: 'Autumn Holidays', start: [10, 12], end: [10, 23], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] },
{ name: 'Christmas Holidays', start: [12, 21], end: [1, 1], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'], yearOffset: 1 },
{ name: 'Christmas Holidays', start: [12, 23], end: [1, 8], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'], yearOffset: 1 },
],
};
export interface CalendarEvent {
date: string; // 'YYYY-MM-DD'
title: string;
type: 'national' | 'canton' | 'school' | 'deadline' | 'expense';
amount?: number;
id?: number;
color: string;
}
function toDateStr(year: number, month: number, day: number): string {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
export function getHolidaysForYear(year: number, canton: string): CalendarEvent[] {
const events: CalendarEvent[] = [];
const easterEntry = EASTER[year];
if (!easterEntry) return events;
const easter = new Date(year, easterEntry[0] - 1, easterEntry[1]);
for (const def of HOLIDAY_DEFS) {
const applies = def.cantons.includes('ALL') || def.cantons.includes(canton);
if (!applies) continue;
let date: Date;
if (def.easterOffset !== undefined) {
date = addDays(easter, def.easterOffset);
} else {
date = new Date(year, def.month! - 1, def.day!);
}
if (date.getFullYear() !== year) continue;
const isNational = def.cantons.includes('ALL');
events.push({
date: toDateStr(date.getFullYear(), date.getMonth() + 1, date.getDate()),
title: def.name,
type: isNational ? 'national' : 'canton',
color: isNational ? '#f97316' : '#ef4444',
});
}
return events;
}
export function getSchoolHolidaysForYear(year: number, canton: string): CalendarEvent[] {
const events: CalendarEvent[] = [];
const defs = SCHOOL_HOLIDAYS_BY_YEAR[year];
if (!defs) return events;
for (const def of defs) {
if (!def.cantons.includes(canton)) continue;
const endYear = year + (def.yearOffset ?? 0);
const start = new Date(year, def.start[0] - 1, def.start[1]);
const end = new Date(endYear, def.end[0] - 1, def.end[1]);
const cur = new Date(start);
while (cur <= end) {
if (cur.getFullYear() === year) {
events.push({
date: toDateStr(cur.getFullYear(), cur.getMonth() + 1, cur.getDate()),
title: def.name,
type: 'school',
color: '#10b981',
});
}
cur.setDate(cur.getDate() + 1);
}
}
return events;
}
@@ -0,0 +1,342 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'expenses.title' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{{ 'expenses.total' | translate }}
<span class="font-semibold text-violet-600 dark:text-violet-400">{{ total() | number:'1.2-2' }} CHF</span>
</p>
</div>
<button (click)="openCreateModal()"
class="inline-flex items-center gap-1.5 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
<!-- Flowbite: outline/general/plus -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
</svg>
{{ 'expenses.add' | translate }}
</button>
</div>
<!-- Table -->
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-5 py-3">{{ 'expenses.col_date' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'expenses.col_name' | translate }}</th>
<th scope="col" class="hidden sm:table-cell px-5 py-3">{{ 'expenses.col_category' | translate }}</th>
<th scope="col" class="hidden md:table-cell px-5 py-3">{{ 'expenses.col_account' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'expenses.col_amount' | translate }}</th>
<th scope="col" class="px-5 py-3"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (expense of expenses(); track expense.id) {
<tr class="border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-5 py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ expense.date | date:'dd.MM.yyyy' }}</td>
<td class="px-5 py-3 font-medium text-gray-900 dark:text-white">{{ expense.name }}</td>
<td class="hidden sm:table-cell px-5 py-3">
<span class="text-xs font-medium px-2.5 py-0.5 rounded-full" [class]="categoryBadgeClass(expense.category)">
{{ categoryLabel(expense.category) | translate }}
</span>
</td>
<td class="hidden md:table-cell px-5 py-3 text-gray-500 dark:text-gray-400">{{ accountName(expense.account) }}</td>
<td class="px-5 py-3 font-semibold text-violet-600 dark:text-violet-400 whitespace-nowrap">{{ expense.amount | number:'1.2-2' }} CHF</td>
<td class="px-5 py-3">
<div class="flex items-center justify-end gap-1">
<button (click)="openEditModal(expense)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-white transition-colors">
<!-- Flowbite: outline/edit/pen-to-square -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
</button>
<button (click)="openDeleteModal(expense.id)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="px-5 py-10 text-center text-sm text-gray-400 dark:text-gray-500">
{{ 'expenses.no_expenses' | translate }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- NO ACCOUNTS MODAL -->
@if (showNoAccountsModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeNoAccountsModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50">
<!-- Flowbite: outline/alerts/info-circle -->
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11h2v5m-2 0h4m-2.592-8.5h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.no_accounts_title' | translate }}</h3>
</div>
<button (click)="closeNoAccountsModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.no_accounts_text' | translate }}</p>
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeNoAccountsModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<a routerLink="/accounts"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.go_to_accounts' | translate }}
</a>
</div>
</div>
</div>
</div>
}
<!-- CREATE MODAL -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeCreateModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'expenses.create_title' | translate }}</h3>
<button type="button" (click)="closeCreateModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="newName" [placeholder]="'expenses.placeholder_name' | translate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_amount' | translate }}</label>
<input type="number" [(ngModel)]="newAmount" placeholder="0.00" step="0.01"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_date' | translate }}</label>
<input type="date" [(ngModel)]="newDate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_category' | translate }}</label>
<select [(ngModel)]="newCategory"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (cat of categories; track cat.key) {
<option [value]="cat.key">{{ cat.label | translate }}</option>
}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_account' | translate }}</label>
<select [(ngModel)]="newAccountId"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'expenses.label_due_date' | translate }}
<span class="font-normal text-gray-400">({{ 'common.optional' | translate }})</span>
</label>
<input type="date" [(ngModel)]="newDueDate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'expenses.label_notes' | translate }}
<span class="font-normal text-gray-400">({{ 'common.optional' | translate }})</span>
</label>
<textarea [(ngModel)]="newNotes" rows="2"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500"></textarea>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeCreateModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="createExpense()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.add' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- EDIT MODAL -->
@if (showEditModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeEditModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'expenses.edit_title' | translate }}</h3>
<button type="button" (click)="closeEditModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="editName"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_amount' | translate }}</label>
<input type="number" [(ngModel)]="editAmount" step="0.01"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_date' | translate }}</label>
<input type="date" [(ngModel)]="editDate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_category' | translate }}</label>
<select [(ngModel)]="editCategory"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (cat of categories; track cat.key) {
<option [value]="cat.key">{{ cat.label | translate }}</option>
}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_account' | translate }}</label>
<select [(ngModel)]="editAccountId"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">
{{ 'expenses.label_due_date' | translate }}
<span class="font-normal text-gray-400">({{ 'common.optional' | translate }})</span>
</label>
<input type="date" [(ngModel)]="editDueDate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'expenses.label_notes' | translate }}</label>
<textarea [(ngModel)]="editNotes" rows="2"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500"></textarea>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeEditModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="updateExpense()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.save' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- DELETE MODAL -->
@if (showDeleteModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeDeleteModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.delete_confirm_title' | translate }}</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.delete_confirm_text' | translate }}</p>
<div class="flex items-center justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="confirmDelete()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900">
{{ 'common.delete' | translate }}
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,210 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
export const EXPENSE_CATEGORIES = [
{ key: 'groceries', label: 'expenses.categories.groceries' },
{ key: 'dining', label: 'expenses.categories.dining' },
{ key: 'transport', label: 'expenses.categories.transport' },
{ key: 'health', label: 'expenses.categories.health' },
{ key: 'clothing', label: 'expenses.categories.clothing' },
{ key: 'electronics', label: 'expenses.categories.electronics' },
{ key: 'household', label: 'expenses.categories.household' },
{ key: 'entertainment', label: 'expenses.categories.entertainment' },
{ key: 'travel', label: 'expenses.categories.travel' },
{ key: 'other', label: 'expenses.categories.other' },
];
@Component({
selector: 'app-expense-list',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, TranslateModule],
templateUrl: './expense-list.html',
styleUrl: './expense-list.css',
})
export class ExpenseList implements OnInit {
expenses = signal<any[]>([]);
accounts = signal<any[]>([]);
categories = EXPENSE_CATEGORIES;
// No Accounts Modal
showNoAccountsModal = signal(false);
// Create Modal
showCreateModal = signal(false);
newName = '';
newAmount = 0;
newDate = '';
newCategory = 'other';
newAccountId: number | null = null;
newNotes = '';
newDueDate = '';
// Edit Modal
showEditModal = signal(false);
editId = 0;
// Delete Modal
showDeleteModal = signal(false);
deleteTargetId = 0;
editName = '';
editAmount = 0;
editDate = '';
editCategory = 'other';
editAccountId: number | null = null;
editNotes = '';
editDueDate = '';
constructor(private api: ApiService) {}
ngOnInit(): void {
this.loadExpenses();
this.loadAccounts();
}
loadExpenses() {
this.api.getExpenses().subscribe({
next: (data) => this.expenses.set(data),
error: (err) => console.error('Error:', err),
});
}
loadAccounts() {
this.api.getAccounts().subscribe({
next: (data) => this.accounts.set(data.filter((a: any) => a.active)),
error: (err) => console.error('Error:', err),
});
}
total(): number {
return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0);
}
categoryLabel(key: string): string {
return EXPENSE_CATEGORIES.find((c) => c.key === key)?.label ?? key;
}
accountName(id: number): string {
return this.accounts().find((a) => a.id === id)?.name ?? '';
}
categoryBadgeClass(key: string): string {
const map: Record<string, string> = {
groceries: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
dining: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
transport: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
health: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
clothing: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
electronics: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
household: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
entertainment: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
travel: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300',
other: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
};
return map[key] ?? map['other'];
}
closeNoAccountsModal() {
this.showNoAccountsModal.set(false);
}
// Create
openCreateModal() {
if (this.accounts().length === 0) {
this.showNoAccountsModal.set(true);
return;
}
this.newName = '';
this.newAmount = 0;
this.newDate = new Date().toISOString().split('T')[0];
this.newCategory = 'other';
this.newAccountId = this.accounts()[0].id;
this.newNotes = '';
this.newDueDate = '';
this.showCreateModal.set(true);
}
closeCreateModal() {
this.showCreateModal.set(false);
}
createExpense() {
if (!this.newName || !this.newAccountId || !this.newDate) return;
this.api.createExpense({
name: this.newName,
amount: this.newAmount,
date: this.newDate,
category: this.newCategory,
account: this.newAccountId,
notes: this.newNotes,
due_date: this.newDueDate || null,
}).subscribe({
next: () => {
this.loadExpenses();
this.closeCreateModal();
},
error: (err) => console.error('Error creating expense:', err),
});
}
// Edit
openEditModal(expense: any) {
this.editId = expense.id;
this.editName = expense.name;
this.editAmount = expense.amount;
this.editDate = expense.date;
this.editCategory = expense.category;
this.editAccountId = expense.account;
this.editNotes = expense.notes;
this.editDueDate = expense.due_date ?? '';
this.showEditModal.set(true);
}
closeEditModal() {
this.showEditModal.set(false);
}
updateExpense() {
if (!this.editName || !this.editAccountId || !this.editDate) return;
this.api.updateExpense(this.editId, {
name: this.editName,
amount: this.editAmount,
date: this.editDate,
category: this.editCategory,
account: this.editAccountId,
notes: this.editNotes,
due_date: this.editDueDate || null,
}).subscribe({
next: () => {
this.loadExpenses();
this.closeEditModal();
},
error: (err) => console.error('Error updating expense:', err),
});
}
// Delete
openDeleteModal(id: number) {
this.deleteTargetId = id;
this.showDeleteModal.set(true);
}
closeDeleteModal() {
this.showDeleteModal.set(false);
this.deleteTargetId = 0;
}
confirmDelete() {
this.api.deleteExpense(this.deleteTargetId).subscribe({
next: () => {
this.loadExpenses();
this.closeDeleteModal();
},
error: (err) => console.error('Error deleting expense:', err),
});
}
}
+12
View File
@@ -0,0 +1,12 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
return router.createUrlTree(['/login']);
};
@@ -0,0 +1,28 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const isInternal = req.url.startsWith('/api');
const token = auth.getToken();
const sessionKey = auth.getSessionKey();
const headers: Record<string, string> = {};
if (token && isInternal) headers['Authorization'] = `Bearer ${token}`;
if (sessionKey && isInternal) headers['X-Session-Key'] = sessionKey;
const authReq = Object.keys(headers).length > 0
? req.clone({ setHeaders: headers })
: req;
return next(authReq).pipe(
catchError((err) => {
if (err.status === 401 && isInternal) {
auth.logout();
}
return throwError(() => err);
})
);
};
+219
View File
@@ -0,0 +1,219 @@
<header class="antialiased">
<nav class="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 px-4 lg:px-6 py-2.5 dark:bg-gray-800 dark:border-gray-700">
<div class="flex justify-between items-center">
<!-- Links: Hamburger + Logo + Suche -->
<div class="flex justify-start items-center">
<!-- Logo -->
<a href="#" class="flex mr-4 items-center">
<img src="assets/Logo_horizontal.svg" alt="Armarium" class="h-7 dark:invert" />
</a>
<!-- Hamburger (Desktop) Toggle -->
<button (click)="sidebarService.toggle()"
class="hidden lg:flex p-2 mr-3 text-gray-600 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700">
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 12">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h14M1 6h14M1 11h7"/>
</svg>
</button>
<!-- Hamburger (Mobile) -->
<button (click)="sidebarService.toggleMobile()"
class="p-2 mr-2 text-gray-600 rounded-lg cursor-pointer lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
<svg class="w-[18px] h-[18px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15"/>
</svg>
<span class="sr-only">Toggle sidebar</span>
</button>
<!-- Search -->
@if (searchOpen()) {
<div class="fixed inset-0 z-40" (click)="closeSearch()"></div>
}
<div class="hidden lg:block lg:pl-2 relative">
<div class="relative mt-1 lg:w-96">
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
</div>
<input type="text"
[value]="searchQuery()"
(input)="onSearchInput($any($event.target).value)"
(keydown.escape)="closeSearch()"
[placeholder]="'nav.search_placeholder' | translate"
class="bg-gray-50 border border-gray-300 text-gray-900 placeholder-gray-400 sm:text-sm rounded-lg focus:ring-violet-500 focus:border-violet-500 block w-full pl-9 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" />
</div>
<!-- Search Dropdown -->
@if (searchOpen()) {
<div class="absolute left-2 top-12 z-50 w-96 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden">
@if (!hasResults()) {
<p class="px-4 py-6 text-sm text-center text-gray-400 dark:text-gray-500">{{ 'nav.no_results' | translate }}</p>
} @else {
@for (cat of searchCategories; track cat.key) {
@if (searchResults()[cat.key]?.length) {
<div>
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ cat.labelKey | translate }}</span>
</div>
@for (item of searchResults()[cat.key]; track item.id) {
<button (click)="navigateToResult(cat, item)"
class="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left">
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path [attr.d]="cat.icon"/>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ item.title }}</p>
<p class="text-xs text-gray-400 truncate">{{ item.subtitle }}</p>
</div>
</button>
}
</div>
}
}
}
</div>
}
</div>
</div>
<!-- Rechts: Notifications + Apps + Avatar (Desktop only — mobile items are in sidebar) -->
<div class="hidden lg:flex items-center lg:order-2">
<!-- Notifications -->
@if (notifOpen) {
<div class="fixed inset-0 z-40" (click)="notifOpen = false"></div>
}
<div class="relative mr-1">
<button (click)="notifOpen = !notifOpen" type="button"
class="relative p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5.365V3m0 2.365a5.338 5.338 0 0 1 5.133 5.368v1.8c0 2.386 1.867 2.982 1.867 4.175 0 .593 0 1.292-.538 1.292H5.538C5 18 5 17.301 5 16.708c0-1.193 1.867-1.789 1.867-4.175v-1.8A5.338 5.338 0 0 1 12 5.365Zm-8.134 5.368a8.458 8.458 0 0 1 2.252-5.714m14.016 5.714a8.458 8.458 0 0 1-2.252-5.714M8.54 17.901a3.48 3.48 0 0 0 6.92 0H8.54Z"/>
</svg>
@if (notifService.notifications().length > 0) {
<span class="absolute top-1 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white dark:border-gray-800"></span>
}
</button>
@if (notifOpen) {
<div class="fixed top-20 left-4 right-4 rounded-lg z-50 lg:absolute lg:left-auto lg:right-0 lg:top-10 lg:w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ 'nav.notifications' | translate }}</span>
@if (notifService.notifications().length > 0) {
<span class="text-xs font-medium text-white bg-red-500 rounded-full px-2 py-0.5">
{{ notifService.notifications().length }}
</span>
}
</div>
@if (notifService.notifications().length > 0) {
<button (click)="notifService.markAllRead()"
class="text-xs font-medium text-violet-600 hover:text-violet-800 dark:text-violet-400 dark:hover:text-violet-300">
{{ 'nav.mark_all_read' | translate }}
</button>
}
</div>
@if (notifService.notifications().length === 0) {
<p class="px-4 py-6 text-sm text-center text-gray-400 dark:text-gray-500">{{ 'nav.no_notifications' | translate }}</p>
} @else {
<ul class="max-h-72 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-700">
@for (n of notifService.notifications(); track n.event_id + n.event_type) {
<li class="flex items-start justify-between gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700">
<button (click)="openNotification(n)" class="flex items-start gap-3 flex-1 text-left">
<span class="mt-0.5 w-2 h-2 rounded-full flex-shrink-0"
[class]="n.event_type === 'deadline' ? 'bg-blue-400' : 'bg-violet-400'"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">{{ n.title }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ n.date }}</p>
</div>
</button>
<button (click)="notifService.markRead(n)" [title]="'nav.mark_read' | translate"
class="text-gray-300 hover:text-emerald-500 dark:hover:text-emerald-400 flex-shrink-0 mt-0.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</li>
}
</ul>
}
</div>
}
</div>
<!-- Apps -->
<div class="relative group">
<button type="button" disabled
class="p-2 text-gray-300 rounded-lg cursor-not-allowed dark:text-gray-600">
<span class="sr-only">Apps</span>
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18">
<path d="M6.143 0H1.857A1.857 1.857 0 0 0 0 1.857v4.286C0 7.169.831 8 1.857 8h4.286A1.857 1.857 0 0 0 8 6.143V1.857A1.857 1.857 0 0 0 6.143 0Zm10 0h-4.286A1.857 1.857 0 0 0 10 1.857v4.286C10 7.169 10.831 8 11.857 8h4.286A1.857 1.857 0 0 0 18 6.143V1.857A1.857 1.857 0 0 0 16.143 0Zm-10 10H1.857A1.857 1.857 0 0 0 0 11.857v4.286C0 17.169.831 18 1.857 18h4.286A1.857 1.857 0 0 0 8 16.143v-4.286A1.857 1.857 0 0 0 6.143 10Zm10 0h-4.286A1.857 1.857 0 0 0 10 11.857v4.286c0 1.026.831 1.857 1.857 1.857h4.286A1.857 1.857 0 0 0 18 16.143v-4.286A1.857 1.857 0 0 0 16.143 10Z"/>
</svg>
</button>
<span class="pointer-events-none absolute right-0 top-10 w-44 px-2 py-1.5 text-xs text-white bg-gray-900 dark:bg-gray-700 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50 text-center">
{{ 'nav.more_coming_soon' | translate }}
</span>
</div>
<!-- Dark/Light Toggle -->
<button (click)="themeService.toggle()" type="button"
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700">
@if (themeService.isDark()) {
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5V3m0 18v-2M7.05 7.05 5.636 5.636m12.728 12.728L16.95 16.95M5 12H3m18 0h-2M7.05 16.95l-1.414 1.414M18.364 5.636 16.95 7.05M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>
</svg>
} @else {
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 21a9 9 0 0 1-.5-17.986V3c-.354.966-.5 1.911-.5 3a9 9 0 0 0 9 9c.239 0 .254.018.488 0A9.004 9.004 0 0 1 12 21Z"/>
</svg>
}
</button>
<!-- Avatar -->
@if (avatarDropdownOpen) {
<div class="fixed inset-0 z-40" (click)="avatarDropdownOpen = false"></div>
}
<div class="relative ml-3">
<button type="button" (click)="avatarDropdownOpen = !avatarDropdownOpen"
class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600 overflow-hidden"
[style.background]="avatarImageUrl() ? 'transparent' : avatarColor()">
@if (avatarImageUrl()) {
<img [src]="avatarImageUrl()" alt="Profile photo" class="w-8 h-8 rounded-full object-cover" />
} @else {
{{ initials() }}
}
</button>
@if (avatarDropdownOpen) {
<div class="absolute right-0 top-10 z-50 w-56 rounded-lg bg-white border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
<div class="py-3 px-4">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ fullName() }}</span>
<span class="block text-sm text-gray-500 truncate dark:text-gray-400">{{ email() }}</span>
</div>
<ul class="py-1">
<li><a routerLink="/profile" (click)="avatarDropdownOpen = false" class="block py-2 px-4 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-white">{{ 'nav.profile' | translate }}</a></li>
<li><a routerLink="/settings" (click)="avatarDropdownOpen = false" class="block py-2 px-4 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-white">{{ 'nav.settings' | translate }}</a></li>
</ul>
<ul class="py-1">
<li>
<button (click)="logout()" class="w-full text-left flex items-center gap-2 py-2 px-4 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-white">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg>
{{ 'nav.sign_out' | translate }}
</button>
</li>
</ul>
</div>
}
</div>
</div>
</div>
</nav>
</header>
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Navbar } from './navbar';
describe('Navbar', () => {
let component: Navbar;
let fixture: ComponentFixture<Navbar>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Navbar],
}).compileComponents();
fixture = TestBed.createComponent(Navbar);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { Component, OnInit, OnDestroy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged, switchMap, of } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
import { AuthService } from '../../services/auth';
import { SidebarService } from '../../services/sidebar';
import { ThemeService } from '../../services/theme';
import { NotificationService, Notification } from '../../services/notification';
import { Router } from '@angular/router';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [CommonModule, RouterModule, TranslateModule],
templateUrl: './navbar.html',
styleUrl: './navbar.css',
})
export class Navbar implements OnInit, OnDestroy {
firstName = signal('');
lastName = signal('');
email = signal('');
avatarColor = signal('#1A56DB');
avatarImageUrl = signal<string | null>(null);
notifOpen = false;
avatarDropdownOpen = false;
// Search
searchQuery = signal('');
searchResults = signal<Record<string, any[]>>({});
searchOpen = signal(false);
private searchSubject = new Subject<string>();
private searchSub: any;
readonly searchCategories: { key: string; labelKey: string; route: string; icon: string }[] = [
{ key: 'accounts', labelKey: 'search.accounts', route: '/accounts', icon: 'M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z' },
{ key: 'budgets', labelKey: 'search.budgets', route: '/budgets', icon: 'M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9z' },
{ key: 'expenses', labelKey: 'search.expenses', route: '/expenses', icon: 'M10 2a8 8 0 100 16A8 8 0 0010 2zm1 11H9v-2h2v2zm0-4H9V5h2v4z' },
{ key: 'transactions', labelKey: 'search.transactions', route: '/transactions', icon: 'M8 5a1 1 0 000 2h5.586l-1.293 1.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L13.586 5H8z' },
{ key: 'deadlines', labelKey: 'search.deadlines', route: '/calendar', icon: 'M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1z' },
];
constructor(
private api: ApiService,
private auth: AuthService,
private router: Router,
public sidebarService: SidebarService,
public themeService: ThemeService,
public notifService: NotificationService,
) {}
openNotification(n: Notification) {
this.notifService.markRead(n);
const [year, month] = n.date.split('-').map(Number);
this.router.navigate(['/calendar'], { queryParams: { year, month } });
this.notifOpen = false;
}
logout(): void {
this.auth.logout();
}
ngOnDestroy(): void {
this.searchSub?.unsubscribe();
}
onSearchInput(value: string) {
this.searchQuery.set(value);
if (value.length < 2) {
this.searchResults.set({});
this.searchOpen.set(false);
return;
}
this.searchSubject.next(value);
this.searchOpen.set(true);
}
navigateToResult(category: { key: string; route: string }, item: any) {
this.searchOpen.set(false);
this.searchQuery.set('');
this.searchResults.set({});
if (category.key === 'deadlines') {
const [year, month] = item.date.split('-').map(Number);
this.router.navigate([category.route], { queryParams: { year, month } });
} else {
this.router.navigate([category.route]);
}
}
closeSearch() {
this.searchOpen.set(false);
this.searchQuery.set('');
this.searchResults.set({});
}
hasResults(): boolean {
return Object.keys(this.searchResults()).length > 0;
}
ngOnInit(): void {
this.searchSub = this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => q.length >= 2 ? this.api.search(q) : of({}))
).subscribe(results => this.searchResults.set(results));
this.api.getProfile().subscribe({
next: (data) => {
this.firstName.set(data.first_name);
this.lastName.set(data.last_name);
this.email.set(data.email);
this.avatarColor.set(data.avatar_color);
if (data.avatar_image) {
this.avatarImageUrl.set(data.avatar_image);
}
},
});
}
initials(): string {
const f = this.firstName().trim();
const l = this.lastName().trim();
if (f && l) return (f[0] + l[0]).toUpperCase();
if (f) return f[0].toUpperCase();
return '?';
}
fullName(): string {
return `${this.firstName()} ${this.lastName()}`.trim() || 'My Account';
}
}
+16
View File
@@ -0,0 +1,16 @@
<app-navbar class="fixed top-0 left-0 right-0 z-50" />
<!-- Mobile sidebar backdrop -->
@if (sidebarService.mobileOpen()) {
<div class="fixed inset-0 z-30 bg-black/50 lg:hidden"
(click)="sidebarService.closeMobile()">
</div>
}
<div class="flex bg-gray-50 dark:bg-gray-900 min-h-screen pt-[57px]">
<app-sidebar />
<main [class]="sidebarService.collapsed() ? 'lg:ml-16' : 'lg:ml-64'"
class="flex-1 p-4 lg:p-8 transition-all duration-300 min-w-0">
<router-outlet />
</main>
</div>
+32
View File
@@ -0,0 +1,32 @@
import { Component, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Navbar } from '../navbar/navbar';
import { Sidebar } from '../sidebar/sidebar';
import { SidebarService } from '../../services/sidebar';
import { NotificationService } from '../../services/notification';
import { ApiService } from '../../services/api';
import { LanguageService } from '../../services/language';
@Component({
selector: 'app-shell',
standalone: true,
imports: [RouterOutlet, Navbar, Sidebar],
templateUrl: './shell.html',
})
export class Shell implements OnInit {
constructor(
public sidebarService: SidebarService,
private notifications: NotificationService,
private api: ApiService,
private langService: LanguageService,
) {}
ngOnInit(): void {
this.notifications.start();
// Load language from profile, then fall back to localStorage / browser
this.api.getProfile().subscribe({
next: (data) => this.langService.init(data.language),
error: () => this.langService.init(),
});
}
}
@@ -0,0 +1,270 @@
<!-- Backdrop: closes flyout when clicking outside -->
@if (sidebarService.openFlyout()) {
<div class="fixed inset-0 z-40" (click)="sidebarService.closeFlyout()"></div>
}
<aside id="default-sidebar"
[class]="(sidebarService.collapsed() ? 'w-16' : 'w-64') + (sidebarService.mobileOpen() ? ' translate-x-0' : ' -translate-x-full lg:translate-x-0')"
class="fixed top-0 left-0 z-40 h-screen pt-14 bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700 transition-all duration-300"
aria-label="Sidenav">
<div [class]="sidebarService.collapsed() ? 'overflow-visible' : 'overflow-y-auto'" class="py-5 px-3 h-full flex flex-col">
<ul class="space-y-2 flex-1">
<!-- Dashboard -->
<li>
<a routerLink="/dashboard" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300"
(click)="sidebarService.closeMobile()"
[class]="sidebarService.collapsed() ? 'justify-center relative' : ''"
class="flex items-center p-2 text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
@if (!sidebarService.collapsed()) {
<span class="ml-3 whitespace-nowrap">{{ 'sidebar.dashboard' | translate }}</span>
}
@if (sidebarService.collapsed()) {
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
{{ 'sidebar.dashboard' | translate }}
</span>
}
</a>
</li>
<!-- Budgets -->
<li class="relative">
@if (sidebarService.collapsed()) {
<!-- Collapsed: icon button opens flyout -->
<button (click)="sidebarService.toggleFlyout('budgets')"
[class]="sidebarService.openFlyout() === 'budgets' ? 'bg-gray-100 dark:bg-gray-700' : ''"
class="relative flex items-center justify-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z"></path>
<path fill-rule="evenodd" d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z" clip-rule="evenodd"></path>
</svg>
@if (sidebarService.openFlyout() !== 'budgets') {
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
{{ 'sidebar.budgets' | translate }}
</span>
}
</button>
<!-- Flyout -->
@if (sidebarService.openFlyout() === 'budgets') {
<div class="absolute left-full top-0 ml-2 z-50 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
<p class="px-3 py-2 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">{{ 'sidebar.budgets' | translate }}</p>
<a routerLink="/budgets" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.fixed_costs' | translate }}
</a>
<a routerLink="/expenses" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.expenses' | translate }}
</a>
</div>
}
} @else {
<!-- Expanded: Angular-controlled dropdown -->
<button type="button" (click)="sidebarService.toggleBudgets()"
class="flex items-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z"></path>
<path fill-rule="evenodd" d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z" clip-rule="evenodd"></path>
</svg>
<span class="flex-1 ml-3 text-left whitespace-nowrap">{{ 'sidebar.budgets' | translate }}</span>
<svg [class.rotate-180]="sidebarService.budgetsOpen()" class="w-4 h-4 transition-transform duration-200" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
@if (sidebarService.budgetsOpen()) {
<ul class="py-2 space-y-2">
<li><a routerLink="/budgets" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.fixed_costs' | translate }}</a></li>
<li><a routerLink="/expenses" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.expenses' | translate }}</a></li>
</ul>
}
}
</li>
<!-- Calendar -->
<li>
<a routerLink="/calendar" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300"
(click)="sidebarService.closeMobile()"
[class]="sidebarService.collapsed() ? 'justify-center relative' : ''"
class="flex items-center p-2 text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
@if (!sidebarService.collapsed()) {
<span class="ml-3 whitespace-nowrap">{{ 'sidebar.calendar' | translate }}</span>
}
@if (sidebarService.collapsed()) {
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
{{ 'sidebar.calendar' | translate }}
</span>
}
</a>
</li>
<!-- Accounts -->
<li class="relative">
@if (sidebarService.collapsed()) {
<!-- Collapsed: icon button opens flyout -->
<button (click)="sidebarService.toggleFlyout('accounts')"
[class]="sidebarService.openFlyout() === 'accounts' ? 'bg-gray-100 dark:bg-gray-700' : ''"
class="relative flex items-center justify-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path>
</svg>
@if (sidebarService.openFlyout() !== 'accounts') {
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
{{ 'sidebar.accounts' | translate }}
</span>
}
</button>
<!-- Flyout -->
@if (sidebarService.openFlyout() === 'accounts') {
<div class="absolute left-full top-0 ml-2 z-50 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
<p class="px-3 py-2 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">{{ 'sidebar.accounts' | translate }}</p>
<a routerLink="/accounts" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.revenue_accounts' | translate }}
</a>
<a routerLink="/transactions" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.transactions' | translate }}
</a>
</div>
}
} @else {
<!-- Expanded: Angular-controlled dropdown -->
<button type="button" (click)="sidebarService.toggleAccounts()"
class="flex items-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path>
</svg>
<span class="flex-1 ml-3 text-left whitespace-nowrap">{{ 'sidebar.accounts' | translate }}</span>
<svg [class.rotate-180]="sidebarService.accountsOpen()" class="w-4 h-4 transition-transform duration-200" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
@if (sidebarService.accountsOpen()) {
<ul class="py-2 space-y-2">
<li><a routerLink="/accounts" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.revenue_accounts' | translate }}</a></li>
<li><a routerLink="/transactions" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.transactions' | translate }}</a></li>
</ul>
}
}
</li>
</ul>
<!-- Mobile: Notifications, Theme, Profile, Logout (hidden on desktop — those are in the navbar) -->
<div class="lg:hidden mt-4 border-t border-gray-200 dark:border-gray-700 pt-2 space-y-0.5">
<!-- Notifications -->
<button type="button" (click)="notifOpen = !notifOpen"
class="flex items-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<div class="relative flex-shrink-0">
<!-- Flowbite: outline/general/bell -->
<svg class="w-6 h-6 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5.365V3m0 2.365a5.338 5.338 0 0 1 5.133 5.368v1.8c0 2.386 1.867 2.982 1.867 4.175 0 .593 0 1.292-.538 1.292H5.538C5 18 5 17.301 5 16.708c0-1.193 1.867-1.789 1.867-4.175v-1.8A5.338 5.338 0 0 1 12 5.365ZM8.733 18c.094.852.306 1.54.944 2.112a3.48 3.48 0 0 0 4.646 0c.638-.572 1.236-1.26 1.33-2.112h-6.92Z"/>
</svg>
@if (notifService.notifications().length > 0) {
<span class="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white dark:border-gray-800"></span>
}
</div>
<span class="ml-3 flex-1 text-left">{{ 'nav.notifications' | translate }}</span>
@if (notifService.notifications().length > 0) {
<span class="text-xs font-medium text-white bg-red-500 rounded-full px-2 py-0.5">{{ notifService.notifications().length }}</span>
}
</button>
@if (notifOpen) {
@if (notifService.notifications().length === 0) {
<p class="px-4 py-3 text-sm text-center text-gray-400 dark:text-gray-500">{{ 'nav.no_notifications' | translate }}</p>
} @else {
<ul class="mb-1 divide-y divide-gray-100 dark:divide-gray-700 border border-gray-100 dark:border-gray-700 rounded-lg overflow-hidden">
@for (n of notifService.notifications(); track n.event_id + n.event_type) {
<li class="flex items-start justify-between gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700">
<button (click)="openNotification(n)" class="flex items-start gap-2 flex-1 text-left">
<span class="mt-1.5 w-2 h-2 rounded-full flex-shrink-0"
[class]="n.event_type === 'deadline' ? 'bg-blue-400' : 'bg-violet-400'"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white leading-tight">{{ n.title }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ n.date }}</p>
</div>
</button>
<button (click)="notifService.markRead(n)" class="text-gray-300 hover:text-gray-500 dark:hover:text-gray-300 flex-shrink-0 mt-0.5">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</li>
}
</ul>
}
}
<!-- Dark/Light Toggle -->
<button (click)="themeService.toggle()" type="button"
class="flex items-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
@if (themeService.isDark()) {
<!-- Flowbite: solid/weather/sun -->
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M13 3a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V3ZM6.343 4.929A1 1 0 0 0 4.93 6.343l1.414 1.414a1 1 0 0 0 1.414-1.414L6.343 4.929Zm12.728 1.414a1 1 0 0 0-1.414-1.414l-1.414 1.414a1 1 0 0 0 1.414 1.414l1.414-1.414ZM12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm-9 4a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H3Zm16 0a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2h-2ZM7.757 17.657a1 1 0 1 0-1.414-1.414l-1.414 1.414a1 1 0 1 0 1.414 1.414l1.414-1.414Zm9.9-1.414a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414ZM13 19a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Z" clip-rule="evenodd"/>
</svg>
<span class="ml-3">{{ 'nav.light_mode' | translate }}</span>
} @else {
<!-- Flowbite: solid/weather/moon -->
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
</svg>
<span class="ml-3">{{ 'nav.dark_mode' | translate }}</span>
}
</button>
<!-- Profile -->
<a routerLink="/profile" (click)="sidebarService.closeMobile()"
class="flex items-center p-2 text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">
<div class="flex items-center justify-center w-6 h-6 rounded-full text-white text-xs font-bold overflow-hidden flex-shrink-0"
[style.background]="avatarImageUrl() ? 'transparent' : avatarColor()">
@if (avatarImageUrl()) {
<img [src]="avatarImageUrl()" alt="Avatar" class="w-6 h-6 rounded-full object-cover" />
} @else {
{{ initials() }}
}
</div>
<span class="ml-3">{{ 'nav.profile' | translate }}</span>
</a>
<!-- Settings -->
<a routerLink="/settings" (click)="sidebarService.closeMobile()"
class="flex items-center p-2 text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 0 1-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 0 1 .947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 0 1 2.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 0 1 2.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 0 1 .947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 0 1-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 0 1-2.287-.947zM10 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" clip-rule="evenodd"/>
</svg>
<span class="ml-3">{{ 'nav.settings' | translate }}</span>
</a>
<!-- Logout -->
<button (click)="logout()"
class="flex items-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<!-- Flowbite: outline/arrows/arrow-right-to-bracket -->
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg>
<span class="ml-3">{{ 'nav.sign_out' | translate }}</span>
</button>
</div>
<!-- Version -->
@if (!sidebarService.collapsed()) {
<div class="pt-5 mt-5 border-t border-gray-200 dark:border-gray-700 px-2">
<p class="text-xs text-gray-400 dark:text-gray-500">Version 1.1.0</p>
</div>
}
</div>
</aside>
@@ -0,0 +1,63 @@
import { Component, OnInit, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { SidebarService } from '../../services/sidebar';
import { ThemeService } from '../../services/theme';
import { NotificationService, Notification } from '../../services/notification';
import { AuthService } from '../../services/auth';
import { ApiService } from '../../services/api';
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive, CommonModule, TranslateModule],
templateUrl: './sidebar.html',
styleUrl: './sidebar.css',
})
export class Sidebar implements OnInit {
notifOpen = false;
private firstName = signal('');
private lastName = signal('');
avatarColor = signal('#1A56DB');
avatarImageUrl = signal<string | null>(null);
constructor(
public sidebarService: SidebarService,
public themeService: ThemeService,
public notifService: NotificationService,
private auth: AuthService,
private api: ApiService,
private router: Router,
) {}
ngOnInit() {
this.api.getProfile().subscribe({
next: (data) => {
this.firstName.set(data.first_name);
this.lastName.set(data.last_name);
this.avatarColor.set(data.avatar_color);
if (data.avatar_image) this.avatarImageUrl.set(data.avatar_image);
},
});
}
initials(): string {
const f = this.firstName().trim();
const l = this.lastName().trim();
if (f && l) return (f[0] + l[0]).toUpperCase();
if (f) return f[0].toUpperCase();
return '?';
}
openNotification(n: Notification) {
this.notifService.markRead(n);
const [year, month] = n.date.split('-').map(Number);
this.router.navigate(['/calendar'], { queryParams: { year, month } });
this.sidebarService.closeMobile();
}
logout() {
this.auth.logout();
}
}
+183
View File
@@ -0,0 +1,183 @@
<div class="max-w-2xl mx-auto space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'profile.title' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ 'profile.subtitle' | translate }}</p>
</div>
<!-- Avatar + Name Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-5">{{ 'profile.personal_info' | translate }}</h2>
<!-- Avatar Preview -->
<div class="flex items-center gap-5 mb-6">
<div class="relative flex-shrink-0 group cursor-pointer" (click)="fileInput.click()">
@if (avatarImageUrl()) {
<img [src]="avatarImageUrl()" alt="Profile photo" class="w-16 h-16 rounded-full object-cover" />
} @else {
<div class="w-16 h-16 rounded-full flex items-center justify-center text-white text-2xl font-bold"
[style.background]="avatarColor()">
{{ initials() }}
</div>
}
<div class="absolute inset-0 rounded-full bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<!-- Flowbite: outline/media/camera -->
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18V10m-4 8V14m8 4v-2M4.2 19H19.8a1.2 1.2 0 0 0 1.2-1.2V8.8a1.2 1.2 0 0 0-1.2-1.2H4.2A1.2 1.2 0 0 0 3 8.8v9a1.2 1.2 0 0 0 1.2 1.2ZM14 5h1a2 2 0 0 1 2 2v1H7V7a2 2 0 0 1 2-2h1m4 0V4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1m4 0H10"/>
</svg>
</div>
<input #fileInput type="file" accept="image/*" class="hidden" (change)="onImageSelected($event)" />
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ 'profile.profile_photo' | translate }}</p>
<p class="text-xs text-gray-400 mb-3">{{ 'profile.photo_hint' | translate }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ 'profile.fallback_color' | translate }}</p>
<div class="flex gap-2 flex-wrap">
@for (color of avatarColors; track color) {
<button (click)="avatarColor.set(color)"
class="w-7 h-7 rounded-full border-2 transition-all"
[style.background]="color"
[class.border-gray-900]="avatarColor() === color"
[class.dark:border-white]="avatarColor() === color"
[class.border-transparent]="avatarColor() !== color">
</button>
}
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.first_name' | translate }}</label>
<input type="text" [value]="firstName()" (input)="firstName.set($any($event.target).value)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.last_name' | translate }}</label>
<input type="text" [value]="lastName()" (input)="lastName.set($any($event.target).value)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
</div>
<div class="grid grid-cols-1 gap-4 mb-4 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.email' | translate }}</label>
<input type="email" [value]="email()" (input)="email.set($any($event.target).value)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.canton' | translate }}</label>
<select [ngModel]="canton()" (ngModelChange)="canton.set($event)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (c of cantons; track c.code) {
<option [value]="c.code">{{ c.code }} {{ ('canton_names.' + c.code) | translate }}</option>
}
</select>
</div>
</div>
<div class="mb-5">
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.language' | translate }}</label>
<select [ngModel]="language()" (ngModelChange)="language.set($event)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
@for (lang of languages; track lang.code) {
<option [value]="lang.code">{{ lang.labelKey | translate }}</option>
}
</select>
</div>
@if (saveSuccess()) {
<div class="mb-4 flex items-center gap-2 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<!-- Flowbite: outline/alerts/check-circle -->
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'profile.save_success' | translate }}
</div>
}
<button (click)="saveProfile()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
{{ 'profile.save_changes' | translate }}
</button>
</div>
<!-- Password Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-5">{{ 'profile.change_password' | translate }}</h2>
<div class="space-y-4 mb-5">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.new_password' | translate }}</label>
<div class="relative">
<input [type]="showNewPassword() ? 'text' : 'password'" [(ngModel)]="newPassword" autocomplete="new-password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button type="button" (click)="showNewPassword.set(!showNewPassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showNewPassword()) {
<!-- Flowbite: outline/general/eye-slash -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<!-- Flowbite: outline/general/eye -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.confirm_password' | translate }}</label>
<div class="relative">
<input [type]="showConfirmPassword() ? 'text' : 'password'" [(ngModel)]="confirmPassword" autocomplete="new-password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button type="button" (click)="showConfirmPassword.set(!showConfirmPassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showConfirmPassword()) {
<!-- Flowbite: outline/general/eye-slash -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<!-- Flowbite: outline/general/eye -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
</div>
</div>
@if (passwordError()) {
<div class="mb-4 flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<!-- Flowbite: outline/alerts/circle-exclamation -->
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ passwordError() }}
</div>
}
@if (passwordSuccess()) {
<div class="mb-4 flex items-center gap-2 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<!-- Flowbite: outline/alerts/check-circle -->
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'profile.password_success' | translate }}
</div>
}
<button (click)="savePassword()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
{{ 'profile.update_password' | translate }}
</button>
</div>
</div>
+138
View File
@@ -0,0 +1,138 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../services/api';
import { AuthService } from '../services/auth';
import { LanguageService } from '../services/language';
import { TranslateService } from '@ngx-translate/core';
import { CANTONS, Canton } from '../data/swiss-holidays';
export const AVATAR_COLORS = [
'#1A56DB', '#057A55', '#9061F9', '#E74694',
'#FF5A1F', '#0694A2', '#C27803', '#1C64F2',
];
export const SUPPORTED_LANGUAGES = [
{ code: 'de', labelKey: 'profile.languages.de' },
{ code: 'fr', labelKey: 'profile.languages.fr' },
{ code: 'it', labelKey: 'profile.languages.it' },
{ code: 'en', labelKey: 'profile.languages.en' },
];
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './profile.html',
styleUrl: './profile.css',
})
export class Profile implements OnInit {
avatarColors = AVATAR_COLORS;
cantons: Canton[] = CANTONS;
canton = signal('ZH');
language = signal('de');
languages = SUPPORTED_LANGUAGES;
firstName = signal('');
lastName = signal('');
email = signal('');
avatarColor = signal('#1A56DB');
avatarImageUrl = signal<string | null>(null);
selectedImageFile: File | null = null;
newPassword = '';
confirmPassword = '';
passwordError = signal('');
passwordSuccess = signal(false);
showNewPassword = signal(false);
showConfirmPassword = signal(false);
saveSuccess = signal(false);
constructor(
private api: ApiService,
private auth: AuthService,
private langService: LanguageService,
private translate: TranslateService,
) {}
ngOnInit(): void {
this.api.getProfile().subscribe({
next: (data) => {
this.firstName.set(data.first_name);
this.lastName.set(data.last_name);
this.email.set(data.email);
this.avatarColor.set(data.avatar_color);
if (data.canton) this.canton.set(data.canton);
if (data.language) this.language.set(data.language);
if (data.avatar_image) {
this.avatarImageUrl.set(data.avatar_image);
}
},
});
}
onImageSelected(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.selectedImageFile = file;
const reader = new FileReader();
reader.onload = (e) => this.avatarImageUrl.set(e.target?.result as string);
reader.readAsDataURL(file);
}
initials(): string {
const f = this.firstName().trim();
const l = this.lastName().trim();
if (f && l) return (f[0] + l[0]).toUpperCase();
if (f) return f[0].toUpperCase();
return '?';
}
saveProfile(): void {
const data: any = {
first_name: this.firstName(),
last_name: this.lastName(),
email: this.email(),
avatar_color: this.avatarColor(),
canton: this.canton(),
language: this.language(),
};
if (this.selectedImageFile) {
data['avatar_image'] = this.selectedImageFile;
}
this.api.updateProfile(data).subscribe({
next: (res) => {
if (res.avatar_image) {
this.avatarImageUrl.set(res.avatar_image);
}
this.selectedImageFile = null;
this.langService.setLanguage(this.language());
this.saveSuccess.set(true);
setTimeout(() => this.saveSuccess.set(false), 3000);
},
});
}
savePassword(): void {
this.passwordError.set('');
const t = (k: string) => this.translate.instant(k);
if (!this.newPassword) { this.passwordError.set(t('profile.errors.password_empty')); return; }
if (this.newPassword !== this.confirmPassword) { this.passwordError.set(t('profile.errors.passwords_mismatch')); return; }
if (this.newPassword.length < 8) { this.passwordError.set(t('profile.errors.password_too_short')); return; }
this.api.changePassword(this.newPassword).subscribe({
next: () => {
this.passwordSuccess.set(true);
this.newPassword = '';
this.confirmPassword = '';
this.showNewPassword.set(false);
this.showConfirmPassword.set(false);
setTimeout(() => this.passwordSuccess.set(false), 3000);
},
error: () => this.passwordError.set(this.translate.instant('profile.errors.password_failed')),
});
}
}
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { Api } from './api';
describe('Api', () => {
let service: Api;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Api);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+197
View File
@@ -0,0 +1,197 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private baseUrl = '/api';
constructor(private http: HttpClient) { }
// Accounts
getAccounts(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/accounts/`);
}
createAccount(account: {name: string, balance: number, account_type: string}): Observable<any> {
return this.http.post(`${this.baseUrl}/accounts/`, account);
}
updateAccount(id: number, account: {name: string, balance: number, account_type: string}): Observable<any> {
return this.http.put(`${this.baseUrl}/accounts/${id}/`, account);
}
deleteAccount(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/accounts/${id}/`);
}
// Budgets
getBudgets(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/budgets/`);
}
createBudget(budget: any): Observable<any> {
return this.http.post(`${this.baseUrl}/budgets/`, budget);
}
updateBudget(id: number, budget: any): Observable<any> {
return this.http.put(`${this.baseUrl}/budgets/${id}/`, budget);
}
deleteBudget(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/budgets/${id}/`);
}
// Transactions
getTransactions(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/transactions/`);
}
createTransaction(transaction: any): Observable<any> {
return this.http.post(`${this.baseUrl}/transactions/`, transaction);
}
updateTransaction(id: number, transaction: any): Observable<any> {
return this.http.put(`${this.baseUrl}/transactions/${id}/`, transaction);
}
deleteTransaction(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/transactions/${id}/`);
}
// Expenses
getExpenses(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/expenses/`);
}
createExpense(expense: any): Observable<any> {
return this.http.post(`${this.baseUrl}/expenses/`, expense);
}
updateExpense(id: number, expense: any): Observable<any> {
return this.http.put(`${this.baseUrl}/expenses/${id}/`, expense);
}
deleteExpense(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/expenses/${id}/`);
}
// Profile
getProfile(): Observable<any> {
return this.http.get(`${this.baseUrl}/profile/`);
}
updateProfile(data: any): Observable<any> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (data[key] !== null && data[key] !== undefined) {
formData.append(key, data[key]);
}
});
return this.http.put(`${this.baseUrl}/profile/`, formData);
}
deleteProfile(password: string): Observable<any> {
return this.http.delete(`${this.baseUrl}/profile/`, { body: { password } });
}
changePassword(password: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/password/`, { password });
}
// Deadlines
getDeadlines(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/deadlines/`);
}
createDeadline(d: any): Observable<any> {
return this.http.post(`${this.baseUrl}/deadlines/`, d);
}
updateDeadline(id: number, d: any): Observable<any> {
return this.http.put(`${this.baseUrl}/deadlines/${id}/`, d);
}
deleteDeadline(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/deadlines/${id}/`);
}
getICalUrl(): Observable<{ url: string }> {
return this.http.get<{ url: string }>(`${this.baseUrl}/calendar/ical-url/`);
}
search(q: string): Observable<Record<string, any[]>> {
return this.http.get<Record<string, any[]>>(`${this.baseUrl}/search/?q=${encodeURIComponent(q)}`);
}
getNotifications(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/notifications/`);
}
markNotificationRead(event_type: string, event_id: number): Observable<any> {
return this.http.post(`${this.baseUrl}/notifications/`, { event_type, event_id });
}
// 2FA
get2FASetup(): Observable<{ secret: string; uri: string }> {
return this.http.get<{ secret: string; uri: string }>(`${this.baseUrl}/auth/2fa/setup/`);
}
enable2FA(code: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/2fa/enable/`, { code });
}
disable2FA(code: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/2fa/disable/`, { code });
}
login2FA(temp_token: string, code: string): Observable<{ access: string; refresh: string; session_key?: string }> {
return this.http.post<{ access: string; refresh: string; session_key?: string }>(`${this.baseUrl}/auth/2fa/login/`, { temp_token, code });
}
request2FARecovery(temp_token: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/2fa/recover/`, { temp_token });
}
confirm2FARecovery(temp_token: string, recovery_code: string): Observable<{ access: string; refresh: string; session_key?: string }> {
return this.http.post<{ access: string; refresh: string; session_key?: string }>(`${this.baseUrl}/auth/2fa/recover/confirm/`, { temp_token, recovery_code });
}
// Sessions
getSessions(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/auth/sessions/`);
}
revokeSession(sessionKey: string): Observable<any> {
return this.http.delete(`${this.baseUrl}/auth/sessions/${sessionKey}/`);
}
revokeAllOtherSessions(): Observable<any> {
return this.http.delete(`${this.baseUrl}/auth/sessions/revoke-all/`);
}
// Data export
downloadExport(): Observable<Blob> {
return this.http.get(`${this.baseUrl}/export/`, { responseType: 'blob' });
}
// Notification preferences
updateNotificationPrefs(prefs: { notif_deadlines?: boolean; notif_budget_alerts?: boolean; notif_monthly_summary?: boolean }): Observable<any> {
return this.http.patch(`${this.baseUrl}/notifications/prefs/`, prefs);
}
// Email verification & password reset
verifyEmail(token: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/verify-email/`, { token });
}
requestPasswordReset(email: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/password-reset/`, { email });
}
confirmPasswordReset(token: string, password: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/password-reset/confirm/`, { token, password });
}
}
+83
View File
@@ -0,0 +1,83 @@
import { Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { tap } from 'rxjs/operators';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly BASE = '/api/auth';
private readonly ACCESS_KEY = 'access_token';
private readonly REFRESH_KEY = 'refresh_token';
private readonly SESSION_KEY = 'session_key';
isLoggedIn = signal(!!this.getToken());
constructor(private http: HttpClient, private router: Router) {}
login(email: string, password: string, keepSignedIn: boolean, turnstileToken = ''): Observable<any> {
return this.http.post<any>(`${this.BASE}/token/`, { username: email, password, cf_turnstile_response: turnstileToken }).pipe(
tap((res) => {
if (res.access) {
this.storeTokens(res.access, res.refresh, keepSignedIn, res.session_key);
this.isLoggedIn.set(true);
}
})
);
}
completeLogin(access: string, refresh: string, keepSignedIn: boolean, sessionKey?: string): void {
this.storeTokens(access, refresh, keepSignedIn, sessionKey);
this.isLoggedIn.set(true);
}
register(email: string, password: string, turnstileToken = ''): Observable<any> {
return this.http.post(`${this.BASE}/register/`, { email, password, cf_turnstile_response: turnstileToken });
}
logout(): void {
const refresh = this.getRefreshToken();
if (refresh) {
this.http.post(`${this.BASE}/logout/`, { refresh }).subscribe({ error: () => {} });
}
localStorage.removeItem(this.ACCESS_KEY);
localStorage.removeItem(this.REFRESH_KEY);
localStorage.removeItem(this.SESSION_KEY);
sessionStorage.removeItem(this.ACCESS_KEY);
sessionStorage.removeItem(this.REFRESH_KEY);
sessionStorage.removeItem(this.SESSION_KEY);
this.isLoggedIn.set(false);
this.router.navigate(['/login']);
}
getToken(): string | null {
return sessionStorage.getItem(this.ACCESS_KEY) ?? localStorage.getItem(this.ACCESS_KEY);
}
getSessionKey(): string | null {
return sessionStorage.getItem(this.SESSION_KEY) ?? localStorage.getItem(this.SESSION_KEY);
}
refreshToken(): Observable<any> {
const refresh = this.getRefreshToken();
const keepSignedIn = !!localStorage.getItem(this.REFRESH_KEY);
return this.http.post<any>(`${this.BASE}/token/refresh/`, { refresh }).pipe(
tap((tokens) => {
this.storeTokens(tokens.access, tokens.refresh, keepSignedIn);
})
);
}
private storeTokens(access: string, refresh: string, keepSignedIn: boolean, sessionKey?: string): void {
const store = keepSignedIn ? localStorage : sessionStorage;
store.setItem(this.ACCESS_KEY, access);
store.setItem(this.REFRESH_KEY, refresh);
if (sessionKey) {
store.setItem(this.SESSION_KEY, sessionKey);
}
}
private getRefreshToken(): string | null {
return sessionStorage.getItem(this.REFRESH_KEY) ?? localStorage.getItem(this.REFRESH_KEY);
}
}
+91
View File
@@ -0,0 +1,91 @@
// School holidays and public holidays data via OpenHolidays API (openholidaysapi.org)
// License: AGPL-3.0 — see https://github.com/openpotato/openholidaysapi
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { CalendarEvent, getHolidaysForYear, getSchoolHolidaysForYear } from '../data/swiss-holidays';
const BASE_URL = 'https://openholidaysapi.org';
@Injectable({ providedIn: 'root' })
export class HolidaysService {
private cache = new Map<string, CalendarEvent[]>();
constructor(private http: HttpClient) {}
getPublicHolidays(year: number, canton: string, lang = 'de'): Observable<CalendarEvent[]> {
const apiLang = lang.toUpperCase();
const key = `pub-${year}-${canton}-${apiLang}`;
if (this.cache.has(key)) return of(this.cache.get(key)!);
const url = `${BASE_URL}/PublicHolidays?countryIsoCode=CH&subdivisionCode=CH-${canton}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=${apiLang}`;
return this.http.get<any[]>(url).pipe(
map(data => this.mapPublicHolidays(data, year, apiLang)),
tap(events => this.cache.set(key, events)),
catchError(() => of(getHolidaysForYear(year, canton)))
);
}
getSchoolHolidays(year: number, canton: string, lang = 'de'): Observable<CalendarEvent[]> {
const apiLang = lang.toUpperCase();
const key = `school-${year}-${canton}-${apiLang}`;
if (this.cache.has(key)) return of(this.cache.get(key)!);
const url = `${BASE_URL}/SchoolHolidays?countryIsoCode=CH&subdivisionCode=CH-${canton}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=${apiLang}`;
return this.http.get<any[]>(url).pipe(
map(data => this.mapSchoolHolidays(data, year, apiLang)),
tap(events => this.cache.set(key, events)),
catchError(() => of(getSchoolHolidaysForYear(year, canton)))
);
}
private toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
private expandDays(startDate: string, endDate: string, year: number): Date[] {
const days: Date[] = [];
const cur = new Date(startDate);
const end = new Date(endDate);
while (cur <= end) {
if (cur.getFullYear() === year) days.push(new Date(cur));
cur.setDate(cur.getDate() + 1);
}
return days;
}
private mapPublicHolidays(data: any[], year: number, lang: string): CalendarEvent[] {
const events: CalendarEvent[] = [];
for (const h of data) {
const name = h.name?.find((n: any) => n.language === lang)?.text ?? h.name?.find((n: any) => n.language === 'DE')?.text ?? h.name?.[0]?.text ?? '';
const isNational = h.nationwide === true;
for (const d of this.expandDays(h.startDate, h.endDate, year)) {
events.push({
date: this.toDateStr(d),
title: name,
type: isNational ? 'national' : 'canton',
color: isNational ? '#f97316' : '#ef4444',
});
}
}
return events;
}
private mapSchoolHolidays(data: any[], year: number, lang: string): CalendarEvent[] {
const events: CalendarEvent[] = [];
for (const h of data) {
const name = h.name?.find((n: any) => n.language === lang)?.text ?? h.name?.find((n: any) => n.language === 'DE')?.text ?? h.name?.[0]?.text ?? '';
for (const d of this.expandDays(h.startDate, h.endDate, year)) {
events.push({
date: this.toDateStr(d),
title: name,
type: 'school',
color: '#10b981',
});
}
}
return events;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
const SUPPORTED_LANGS = ['de', 'fr', 'it', 'en'];
const STORAGE_KEY = 'app_language';
@Injectable({ providedIn: 'root' })
export class LanguageService {
constructor(private translate: TranslateService) {
translate.addLangs(SUPPORTED_LANGS);
translate.setDefaultLang('de');
// Load the persisted or browser-detected language immediately so
// translations are available before any component calls init().
const stored = localStorage.getItem(STORAGE_KEY);
const initial = stored ?? this.detectBrowserLanguage();
translate.use(initial);
}
/** Call once at app startup (e.g. in AppComponent or Shell). */
init(profileLanguage?: string): void {
const lang = profileLanguage
?? localStorage.getItem(STORAGE_KEY)
?? this.detectBrowserLanguage()
?? 'de';
this.applyLanguage(lang);
}
/** Used on the register page to pre-select the browser language. */
detectBrowserLanguage(): string {
const raw = navigator.language?.split('-')[0].toLowerCase();
return SUPPORTED_LANGS.includes(raw) ? raw : 'de';
}
setLanguage(lang: string): void {
if (!SUPPORTED_LANGS.includes(lang)) return;
this.applyLanguage(lang);
localStorage.setItem(STORAGE_KEY, lang);
}
get current(): string {
return this.translate.currentLang || 'de';
}
private applyLanguage(lang: string): void {
const safe = SUPPORTED_LANGS.includes(lang) ? lang : 'de';
this.translate.use(safe);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { Injectable, signal, OnDestroy } from '@angular/core';
import { ApiService } from './api';
export interface Notification {
event_type: 'deadline' | 'expense';
event_id: number;
title: string;
date: string;
}
@Injectable({ providedIn: 'root' })
export class NotificationService implements OnDestroy {
notifications = signal<Notification[]>([]);
private intervalId: any;
constructor(private api: ApiService) {}
start() {
this.load();
this.intervalId = setInterval(() => this.load(), 60_000);
}
stop() {
clearInterval(this.intervalId);
}
ngOnDestroy() {
this.stop();
}
private load() {
this.api.getNotifications().subscribe({
next: (data) => this.notifications.set(data),
error: () => {},
});
}
markRead(notification: Notification) {
this.api.markNotificationRead(notification.event_type, notification.event_id).subscribe({
next: () => {
this.notifications.update(list =>
list.filter(n => !(n.event_type === notification.event_type && n.event_id === notification.event_id))
);
},
});
}
markAllRead() {
const current = this.notifications();
current.forEach(n =>
this.api.markNotificationRead(n.event_type, n.event_id).subscribe()
);
this.notifications.set([]);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { Sidebar } from './sidebar';
describe('Sidebar', () => {
let service: Sidebar;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Sidebar);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class SidebarService {
collapsed = signal(false);
openFlyout = signal<string | null>(null);
mobileOpen = signal(false);
budgetsOpen = signal(false);
accountsOpen = signal(false);
toggle() {
this.collapsed.update(v => !v);
this.openFlyout.set(null);
}
toggleMobile() {
this.mobileOpen.update(v => !v);
}
closeMobile() {
this.mobileOpen.set(false);
}
toggleBudgets() {
this.budgetsOpen.update(v => !v);
}
toggleAccounts() {
this.accountsOpen.update(v => !v);
}
toggleFlyout(name: string) {
this.openFlyout.update(current => current === name ? null : name);
}
closeFlyout() {
this.openFlyout.set(null);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ThemeService {
isDark = signal(false);
constructor() {
const saved = localStorage.getItem('theme');
if (saved) {
this.isDark.set(saved === 'dark');
} else {
this.isDark.set(window.matchMedia('(prefers-color-scheme: dark)').matches);
}
this.applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
this.isDark.set(e.matches);
this.applyTheme();
}
});
}
toggle(): void {
this.isDark.update(v => !v);
this.applyTheme();
localStorage.setItem('theme', this.isDark() ? 'dark' : 'light');
}
private applyTheme(): void {
document.documentElement.classList.toggle('dark', this.isDark());
}
}
+452
View File
@@ -0,0 +1,452 @@
<div class="max-w-2xl mx-auto space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'nav.settings' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ 'settings.subtitle' | translate }}</p>
</div>
<!-- Recovery Email Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-1">{{ 'settings.recovery_email' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">{{ 'settings.recovery_email_hint' | translate }}</p>
<div class="flex flex-col gap-2 sm:flex-row">
<input type="email" [(ngModel)]="recoveryEmail" placeholder="backup@example.com"
class="block flex-1 rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
<button (click)="saveRecoveryEmail()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors whitespace-nowrap">
{{ 'common.save' | translate }}
</button>
</div>
@if (recoveryEmailSaved()) {
<div class="mt-3 flex items-center gap-2 rounded-lg bg-emerald-50 px-3 py-2 text-xs text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'settings.recovery_email_saved' | translate }}
</div>
}
</div>
<!-- 2FA Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'profile.totp_title' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'profile.totp_subtitle' | translate }}</p>
</div>
<span class="text-xs font-medium px-2.5 py-1 rounded-full"
[class]="totpEnabled()
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'">
{{ (totpEnabled() ? 'profile.totp_on' : 'profile.totp_off') | translate }}
</span>
</div>
@if (totpSuccess()) {
<div class="mb-4 flex items-center gap-2 rounded-lg bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ totpSuccess() | translate }}
</div>
}
@if (totpSetupStep() === 'idle') {
@if (!totpEnabled()) {
<button (click)="startEnable2FA()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
{{ 'profile.totp_enable' | translate }}
</button>
} @else {
<button (click)="startDisable2FA()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 transition-colors">
{{ 'profile.totp_disable' | translate }}
</button>
}
}
@if (totpSetupStep() === 'scan') {
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ 'profile.totp_scan_hint' | translate }}</p>
@if (totpQrDataUrl()) {
<div class="flex justify-center">
<img [src]="totpQrDataUrl()!" alt="QR Code"
class="w-48 h-48 rounded-lg border border-gray-200 dark:border-gray-600 bg-white p-1" />
</div>
}
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.totp_code_label' | translate }}</label>
<input type="text" [(ngModel)]="totpCode" maxlength="6" inputmode="numeric" placeholder="000000"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-center text-sm tracking-[0.4em] text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
@if (totpError()) {
<div class="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ totpError() | translate }}
</div>
}
<div class="flex gap-2">
<button (click)="confirmEnable2FA()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
{{ 'profile.totp_confirm' | translate }}
</button>
<button (click)="cancelTotp()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
}
@if (totpSetupStep() === 'disable') {
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ 'profile.totp_disable_hint' | translate }}</p>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'profile.totp_code_label' | translate }}</label>
<input type="text" [(ngModel)]="totpCode" maxlength="6" inputmode="numeric" placeholder="000000"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-center text-sm tracking-[0.4em] text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
@if (totpError()) {
<div class="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ totpError() | translate }}
</div>
}
<div class="flex gap-2">
<button (click)="confirmDisable2FA()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 transition-colors">
{{ 'profile.totp_disable' | translate }}
</button>
<button (click)="cancelTotp()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
}
</div>
<!-- Active Sessions Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-1">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'settings.sessions_title' | translate }}</h2>
@if (sessions().length > 1) {
<button (click)="revokeAllOtherSessions()" [disabled]="revokeAllLoading()"
class="text-xs text-red-600 dark:text-red-400 hover:underline disabled:opacity-50">
{{ 'settings.sessions_revoke_all' | translate }}
</button>
}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">{{ 'settings.sessions_hint' | translate }}</p>
@if (sessionsLoading()) {
<p class="text-sm text-gray-400 dark:text-gray-500">{{ 'settings.sessions_loading' | translate }}</p>
} @else {
<div class="space-y-2">
@for (session of sessions(); track session.session_key) {
<div class="flex items-center justify-between rounded-lg bg-gray-50 dark:bg-gray-900 p-3 gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ session.device_name || ('settings.sessions_unknown_device' | translate) }}
</span>
@if (session.is_current) {
<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300">
{{ 'settings.sessions_current' | translate }}
</span>
}
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ session.ip_address }}
@if (session.ip_address) { · }
{{ session.last_active_at | date:'dd.MM.yyyy HH:mm' }}
</p>
</div>
@if (!session.is_current) {
<button (click)="revokeSession(session.session_key)"
[disabled]="revokeLoading() === session.session_key"
class="shrink-0 text-xs text-red-600 dark:text-red-400 hover:underline disabled:opacity-50">
{{ 'settings.sessions_revoke' | translate }}
</button>
}
</div>
}
</div>
}
</div>
<!-- Data Export Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-1">{{ 'settings.export_title' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">{{ 'settings.export_hint' | translate }}</p>
<button (click)="downloadExport()" [disabled]="exportLoading()"
class="inline-flex items-center gap-2 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
@if (exportLoading()) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ 'settings.export_loading' | translate }}
} @else {
<!-- Flowbite: outline/general/download -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/>
</svg>
{{ 'settings.export_btn' | translate }}
}
</button>
</div>
<!-- Notification Preferences Card -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-1">{{ 'settings.notif_title' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-5">{{ 'settings.notif_hint' | translate }}</p>
<div class="space-y-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'settings.notif_deadlines' | translate }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'settings.notif_deadlines_hint' | translate }}</p>
</div>
<button type="button" (click)="toggleNotif('notif_deadlines')"
[class]="notifPrefs().notif_deadlines ? 'bg-violet-600' : 'bg-gray-200 dark:bg-gray-600'"
class="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors">
<span [class]="notifPrefs().notif_deadlines ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'settings.notif_budget_alerts' | translate }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'settings.notif_budget_alerts_hint' | translate }}</p>
</div>
<button type="button" (click)="toggleNotif('notif_budget_alerts')"
[class]="notifPrefs().notif_budget_alerts ? 'bg-violet-600' : 'bg-gray-200 dark:bg-gray-600'"
class="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors">
<span [class]="notifPrefs().notif_budget_alerts ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ 'settings.notif_monthly_summary' | translate }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ 'settings.notif_monthly_summary_hint' | translate }}</p>
</div>
<button type="button" (click)="toggleNotif('notif_monthly_summary')"
[class]="notifPrefs().notif_monthly_summary ? 'bg-violet-600' : 'bg-gray-200 dark:bg-gray-600'"
class="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors">
<span [class]="notifPrefs().notif_monthly_summary ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform"></span>
</button>
</div>
</div>
<div class="flex items-center gap-3 mt-5">
<button (click)="saveNotifPrefs()" [disabled]="notifSaving()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
{{ 'common.save' | translate }}
</button>
@if (notifSaved()) {
<span class="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'settings.notif_saved' | translate }}
</span>
}
</div>
</div>
<!-- Danger Zone -->
<div class="rounded-lg bg-white border border-red-200 dark:bg-gray-800 dark:border-red-900 p-6">
<h2 class="text-base font-semibold text-red-600 mb-2">{{ 'profile.danger_zone' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ 'profile.danger_text' | translate }}</p>
<button (click)="openDeleteModal()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 transition-colors">
{{ 'profile.delete_account' | translate }}
</button>
</div>
</div>
<!-- BACKUP CODES MODAL -->
@if (backupCodes().length > 0) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50">
<!-- Flowbite: outline/security/lock-key (key icon) -->
<svg class="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 2.5v-5M12 7h.01"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ 'profile.backup_codes_title' | translate }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'profile.backup_codes_hint' | translate }}</p>
</div>
</div>
<div class="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-gray-50 dark:bg-gray-900 p-4">
@for (code of backupCodes(); track code) {
<span class="font-mono text-sm tracking-wider text-gray-800 dark:text-gray-200">{{ code }}</span>
}
</div>
<div class="flex flex-wrap gap-2">
<button (click)="copyBackupCodes()"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
@if (backupCopied()) {
<!-- Flowbite: outline/alerts/check-circle -->
<svg class="w-4 h-4 text-emerald-500" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ 'profile.backup_copied' | translate }}
} @else {
<!-- Flowbite: outline/general/copy -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-6 5h6m-6 4h6M10 3v4h4V3h-4Z"/>
</svg>
{{ 'profile.backup_copy' | translate }}
}
</button>
<button (click)="downloadBackupCodesPdf()"
class="inline-flex items-center gap-1.5 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
<!-- Flowbite: outline/general/download -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/>
</svg>
{{ 'profile.backup_download_pdf' | translate }}
</button>
<button (click)="closeBackupCodes()"
class="ml-auto rounded-lg px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:underline">
{{ 'profile.backup_saved' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- DELETE CONFIRMATION MODAL -->
@if (showDeleteModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeDeleteModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5 text-center">
@if (!exportedBeforeDelete()) {
<!-- Step 1: Export required -->
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40">
<!-- Flowbite: outline/general/download -->
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'settings.delete_step1_title' | translate }}</h3>
<p class="mb-6 text-sm text-gray-500 dark:text-gray-400">{{ 'settings.delete_step1_hint' | translate }}</p>
<div class="flex justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="downloadExport()" [disabled]="exportLoading()"
class="inline-flex items-center gap-2 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 disabled:opacity-50 transition-colors">
@if (exportLoading()) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
}
{{ 'settings.delete_step1_btn' | translate }}
</button>
</div>
} @else {
<!-- Step 2: Credentials + confirmation phrase -->
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<!-- Flowbite: outline/alerts/circle-exclamation -->
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<div class="mb-4 flex items-center justify-center gap-1.5">
<svg class="h-4 w-4 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
<span class="text-xs text-emerald-600 dark:text-emerald-400">{{ 'settings.delete_step2_exported' | translate }}</span>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'profile.delete_account_confirm' | translate }}</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'profile.delete_account_text' | translate }}</p>
<div class="mb-5 space-y-4 text-left">
<div>
<label class="mb-2 block text-xs font-medium text-gray-900 dark:text-white">{{ 'settings.delete_password_label' | translate }}</label>
<div class="relative">
<input [type]="showDeletePassword() ? 'text' : 'password'" [(ngModel)]="deletePassword"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pr-10 text-sm text-gray-900 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-red-500 dark:focus:ring-red-500" />
<button type="button" (click)="showDeletePassword.set(!showDeletePassword())"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
@if (showDeletePassword()) {
<!-- Flowbite: outline/general/eye-slash -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.933 13.909A4.357 4.357 0 0 1 3 12c0-1 4-6 9-6m7.6 3.8A5.068 5.068 0 0 1 21 12c0 1-3 6-9 6-.314 0-.62-.014-.918-.04M5 19 19 5m-4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
} @else {
<!-- Flowbite: outline/general/eye -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="2" d="M21 12c0 1.2-4.03 6-9 6s-9-4.8-9-6c0-1.2 4.03-6 9-6s9 4.8 9 6Z"/>
<path stroke="currentColor" stroke-width="2" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
}
</button>
</div>
</div>
<div>
<label class="mb-2 block text-xs font-medium text-gray-900 dark:text-white">
{{ 'settings.delete_phrase_label' | translate }}
<span class="ml-1 font-mono text-red-600 dark:text-red-400">{{ DELETE_PHRASE }}</span>
</label>
<input type="text" [(ngModel)]="deleteConfirmText" autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-red-500 dark:focus:ring-red-500" />
</div>
@if (deleteError()) {
<div class="flex items-center gap-2 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V8m0 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
{{ deleteError() | translate }}
</div>
}
</div>
<div class="flex justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="confirmDelete()" [disabled]="!deleteFormValid"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{{ 'profile.delete_account' | translate }}
</button>
</div>
}
</div>
</div>
</div>
}
+287
View File
@@ -0,0 +1,287 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import * as QRCode from 'qrcode';
import { ApiService } from '../services/api';
import { AuthService } from '../services/auth';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './settings.html',
})
export class Settings implements OnInit {
// Recovery email
recoveryEmail = '';
recoveryEmailSaved = signal(false);
// 2FA
totpEnabled = signal(false);
totpSetupStep = signal<'idle' | 'scan' | 'disable'>('idle');
totpQrDataUrl = signal<string | null>(null);
totpCode = '';
totpError = signal('');
totpSuccess = signal('');
backupCodes = signal<string[]>([]);
backupCopied = signal(false);
// Active Sessions
sessions = signal<any[]>([]);
sessionsLoading = signal(false);
revokeLoading = signal<string | null>(null);
revokeAllLoading = signal(false);
// Data Export
exportLoading = signal(false);
exportedBeforeDelete = signal(false);
// Notification Preferences
notifPrefs = signal({ notif_deadlines: true, notif_budget_alerts: true, notif_monthly_summary: false });
notifSaving = signal(false);
notifSaved = signal(false);
// Danger Zone
showDeleteModal = signal(false);
showDeletePassword = signal(false);
deletePassword = '';
deleteConfirmText = '';
deleteError = signal('');
get DELETE_PHRASE(): string {
return this.translate.instant('profile.delete_account');
}
constructor(private api: ApiService, private auth: AuthService, private translate: TranslateService) {}
ngOnInit(): void {
this.api.getProfile().subscribe({
next: (data) => {
if (data.totp_enabled !== undefined) this.totpEnabled.set(data.totp_enabled);
if (data.recovery_email !== undefined) this.recoveryEmail = data.recovery_email;
this.notifPrefs.set({
notif_deadlines: data.notif_deadlines ?? true,
notif_budget_alerts: data.notif_budget_alerts ?? true,
notif_monthly_summary: data.notif_monthly_summary ?? false,
});
},
});
this.loadSessions();
}
// ── Recovery email ────────────────────────────────────────────────────────
saveRecoveryEmail(): void {
this.api.updateProfile({ recovery_email: this.recoveryEmail }).subscribe({
next: () => {
this.recoveryEmailSaved.set(true);
setTimeout(() => this.recoveryEmailSaved.set(false), 3000);
},
});
}
// ── 2FA ──────────────────────────────────────────────────────────────────
startEnable2FA(): void {
this.totpError.set('');
this.totpCode = '';
this.api.get2FASetup().subscribe({
next: async (res) => {
const dataUrl = await QRCode.toDataURL(res.uri, { width: 200, margin: 2 });
this.totpQrDataUrl.set(dataUrl);
this.totpSetupStep.set('scan');
},
});
}
confirmEnable2FA(): void {
this.totpError.set('');
this.api.enable2FA(this.totpCode).subscribe({
next: (res) => {
this.totpEnabled.set(true);
this.totpSetupStep.set('idle');
this.totpQrDataUrl.set(null);
this.totpCode = '';
this.backupCodes.set(res.backup_codes ?? []);
},
error: () => this.totpError.set('profile.totp_invalid_code'),
});
}
startDisable2FA(): void {
this.totpError.set('');
this.totpCode = '';
this.totpSetupStep.set('disable');
}
confirmDisable2FA(): void {
this.totpError.set('');
this.api.disable2FA(this.totpCode).subscribe({
next: () => {
this.totpEnabled.set(false);
this.totpSetupStep.set('idle');
this.totpCode = '';
this.totpSuccess.set('profile.totp_disabled_success');
setTimeout(() => this.totpSuccess.set(''), 3000);
},
error: () => this.totpError.set('profile.totp_invalid_code'),
});
}
cancelTotp(): void {
this.totpSetupStep.set('idle');
this.totpCode = '';
this.totpError.set('');
this.totpQrDataUrl.set(null);
}
copyBackupCodes(): void {
navigator.clipboard.writeText(this.backupCodes().join('\n')).then(() => {
this.backupCopied.set(true);
setTimeout(() => this.backupCopied.set(false), 2000);
});
}
async downloadBackupCodesPdf(): Promise<void> {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
const codes = this.backupCodes();
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.text('Armarium — Backup Codes', 20, 24);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(100);
doc.text('Store these codes in a safe place. Each code can only be used once.', 20, 34);
doc.text('Use one if you lose access to your authenticator app.', 20, 40);
doc.setDrawColor(200);
doc.line(20, 46, 190, 46);
doc.setFontSize(14);
doc.setFont('courier', 'normal');
doc.setTextColor(30);
codes.forEach((code, i) => {
doc.text(code, 20, 58 + i * 12);
});
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(150);
doc.text(`Generated ${new Date().toLocaleDateString()} · armarium.app`, 20, 200);
doc.save('armarium-backup-codes.pdf');
}
closeBackupCodes(): void {
this.backupCodes.set([]);
this.totpSuccess.set('profile.totp_enabled_success');
setTimeout(() => this.totpSuccess.set(''), 3000);
}
// ── Active Sessions ───────────────────────────────────────────────────────
loadSessions(): void {
this.sessionsLoading.set(true);
this.api.getSessions().subscribe({
next: (s) => { this.sessions.set(s); this.sessionsLoading.set(false); },
error: () => this.sessionsLoading.set(false),
});
}
revokeSession(key: string): void {
this.revokeLoading.set(key);
this.api.revokeSession(key).subscribe({
next: () => {
this.sessions.set(this.sessions().filter(s => s.session_key !== key));
this.revokeLoading.set(null);
},
error: () => this.revokeLoading.set(null),
});
}
revokeAllOtherSessions(): void {
this.revokeAllLoading.set(true);
this.api.revokeAllOtherSessions().subscribe({
next: () => { this.loadSessions(); this.revokeAllLoading.set(false); },
error: () => this.revokeAllLoading.set(false),
});
}
// ── Data Export ───────────────────────────────────────────────────────────
downloadExport(): void {
this.exportLoading.set(true);
this.api.downloadExport().subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'armarium-export.zip';
a.click();
URL.revokeObjectURL(url);
this.exportLoading.set(false);
this.exportedBeforeDelete.set(true);
},
error: () => this.exportLoading.set(false),
});
}
// ── Notification Preferences ──────────────────────────────────────────────
toggleNotif(key: 'notif_deadlines' | 'notif_budget_alerts' | 'notif_monthly_summary'): void {
this.notifPrefs.set({ ...this.notifPrefs(), [key]: !this.notifPrefs()[key] });
}
saveNotifPrefs(): void {
this.notifSaving.set(true);
this.api.updateNotificationPrefs(this.notifPrefs()).subscribe({
next: () => {
this.notifSaving.set(false);
this.notifSaved.set(true);
setTimeout(() => this.notifSaved.set(false), 3000);
},
error: () => this.notifSaving.set(false),
});
}
// ── Danger Zone ───────────────────────────────────────────────────────────
get deleteFormValid(): boolean {
return !!this.deletePassword && this.deleteConfirmText === this.DELETE_PHRASE;
}
openDeleteModal(): void {
this.deletePassword = '';
this.deleteConfirmText = '';
this.deleteError.set('');
this.showDeletePassword.set(false);
this.showDeleteModal.set(true);
}
closeDeleteModal(): void {
this.showDeleteModal.set(false);
this.deletePassword = '';
this.deleteConfirmText = '';
this.deleteError.set('');
this.showDeletePassword.set(false);
}
confirmDelete(): void {
if (!this.deleteFormValid) return;
this.deleteError.set('');
this.api.deleteProfile(this.deletePassword).subscribe({
next: () => {
this.closeDeleteModal();
localStorage.clear();
sessionStorage.clear();
window.location.href = 'https://www.armarium.ch';
},
error: () => this.deleteError.set('settings.delete_wrong_password'),
});
}
}
@@ -0,0 +1,253 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'transactions.title' | translate }}</h1>
</div>
<button (click)="openCreateModal()"
class="inline-flex items-center gap-1.5 rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800 transition-colors">
<!-- Flowbite: outline/general/plus -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
</svg>
{{ 'transactions.add' | translate }}
</button>
</div>
<!-- Tabelle -->
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-5 py-3">{{ 'transactions.col_date' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'transactions.col_description' | translate }}</th>
<th scope="col" class="hidden sm:table-cell px-5 py-3">{{ 'transactions.col_from' | translate }}</th>
<th scope="col" class="hidden md:table-cell px-5 py-3">{{ 'transactions.col_to' | translate }}</th>
<th scope="col" class="px-5 py-3">{{ 'transactions.col_amount' | translate }}</th>
<th scope="col" class="px-5 py-3"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (transaction of transactions(); track transaction.id) {
<tr class="border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-5 py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ transaction.date | date:'dd.MM.yyyy' }}</td>
<td class="px-5 py-3 font-medium text-gray-900 dark:text-white">{{ transaction.description }}</td>
<td class="hidden sm:table-cell px-5 py-3">{{ accountName(transaction.source_account) }}</td>
<td class="hidden md:table-cell px-5 py-3">{{ accountName(transaction.destination_account) }}</td>
<td class="px-5 py-3 font-semibold text-violet-600 dark:text-violet-400 whitespace-nowrap">
{{ transaction.amount | number:'1.2-2' }} CHF
</td>
<td class="px-5 py-3">
<div class="flex items-center justify-end gap-1">
<button (click)="openEditModal(transaction)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-white transition-colors">
<!-- Flowbite: outline/edit/pen-to-square -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
</button>
<button (click)="openDeleteModal(transaction.id)"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="px-5 py-10 text-center text-sm text-gray-400 dark:text-gray-500">
{{ 'transactions.no_transactions' | translate }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- CREATE MODAL -->
@if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeCreateModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'transactions.create_title' | translate }}</h3>
<button type="button" (click)="closeCreateModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_description' | translate }}</label>
<input type="text" [(ngModel)]="newDescription" [placeholder]="'transactions.placeholder_description' | translate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_amount' | translate }}</label>
<input type="number" [(ngModel)]="newAmount" placeholder="0.00"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_date' | translate }}</label>
<input type="date" [(ngModel)]="newDate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_from' | translate }}</label>
<select [(ngModel)]="newSourceAccount"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="">{{ 'transactions.select_account' | translate }}</option>
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_to' | translate }}</label>
<select [(ngModel)]="newDestinationAccount"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="">{{ 'transactions.select_account' | translate }}</option>
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeCreateModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="createTransaction()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.create' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- EDIT MODAL -->
@if (showEditModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeEditModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'transactions.edit_title' | translate }}</h3>
<button type="button" (click)="closeEditModal()"
class="ml-auto inline-flex items-center rounded-lg bg-transparent p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_description' | translate }}</label>
<input type="text" [(ngModel)]="editDescription"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_amount' | translate }}</label>
<input type="number" [(ngModel)]="editAmount"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_date' | translate }}</label>
<input type="date" [(ngModel)]="editDate"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_from' | translate }}</label>
<select [(ngModel)]="editSourceAccount"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="">{{ 'transactions.select_account' | translate }}</option>
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">{{ 'transactions.label_to' | translate }}</label>
<select [(ngModel)]="editDestinationAccount"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500">
<option value="">{{ 'transactions.select_account' | translate }}</option>
@for (account of accounts(); track account.id) {
<option [value]="account.id">{{ account.name }}</option>
}
</select>
</div>
</div>
<!-- Footer -->
<div class="mt-5 flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-600">
<button (click)="closeEditModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="updateTransaction()"
class="rounded-lg bg-violet-700 px-4 py-2 text-sm font-medium text-white hover:bg-violet-800 focus:outline-none focus:ring-4 focus:ring-violet-300 dark:bg-violet-600 dark:hover:bg-violet-700 dark:focus:ring-violet-800">
{{ 'common.save' | translate }}
</button>
</div>
</div>
</div>
</div>
}
<!-- DELETE MODAL -->
@if (showDeleteModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="absolute inset-0 bg-gray-900/50 dark:bg-gray-900/80" (click)="closeDeleteModal()"></div>
<div class="relative z-10 w-full max-w-md p-4">
<div class="relative rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800 sm:p-5 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<!-- Flowbite: outline/general/trash-bin -->
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</div>
<h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ 'common.delete_confirm_title' | translate }}</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ 'common.delete_confirm_text' | translate }}</p>
<div class="flex items-center justify-center gap-3">
<button (click)="closeDeleteModal()"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700">
{{ 'common.cancel' | translate }}
</button>
<button (click)="confirmDelete()"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900">
{{ 'common.delete' | translate }}
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TransactionList } from './transaction-list';
describe('TransactionList', () => {
let component: TransactionList;
let fixture: ComponentFixture<TransactionList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TransactionList],
}).compileComponents();
fixture = TestBed.createComponent(TransactionList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,148 @@
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
@Component({
selector: 'app-transaction-list',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './transaction-list.html',
styleUrl: './transaction-list.css',
})
export class TransactionList implements OnInit {
transactions = signal<any[]>([]);
accounts = signal<any[]>([]);
// Create Modal
showCreateModal = signal(false);
newDescription = '';
newAmount = 0;
newDate = '';
newSourceAccount = '';
newDestinationAccount = '';
// Edit Modal
showEditModal = signal(false);
editId = 0;
// Delete Modal
showDeleteModal = signal(false);
deleteTargetId = 0;
editDescription = '';
editAmount = 0;
editDate = '';
editSourceAccount = '';
editDestinationAccount = '';
constructor(private api: ApiService) {}
ngOnInit(): void {
this.loadTransactions();
this.loadAccounts();
}
loadTransactions() {
this.api.getTransactions().subscribe({
next: (data) => this.transactions.set(data),
error: (err) => console.error('Fehler:', err)
});
}
loadAccounts() {
this.api.getAccounts().subscribe({
next: (data) => this.accounts.set(data),
error: (err) => console.error('Fehler:', err)
});
}
// Create
openCreateModal() {
this.showCreateModal.set(true);
}
closeCreateModal() {
this.showCreateModal.set(false);
this.newDescription = '';
this.newAmount = 0;
this.newDate = '';
this.newSourceAccount = '';
this.newDestinationAccount = '';
}
createTransaction() {
if (!this.newDescription || !this.newDate) return;
this.api.createTransaction({
description: this.newDescription,
amount: this.newAmount,
date: this.newDate,
source_account: this.newSourceAccount,
destination_account: this.newDestinationAccount
}).subscribe({
next: () => {
this.loadTransactions();
this.closeCreateModal();
},
error: (err) => console.error('Fehler beim Erstellen:', err)
});
}
// Edit
openEditModal(transaction: any) {
this.editId = transaction.id;
this.editDescription = transaction.description;
this.editAmount = transaction.amount;
this.editDate = transaction.date;
this.editSourceAccount = transaction.source_account;
this.editDestinationAccount = transaction.destination_account;
this.showEditModal.set(true);
}
closeEditModal() {
this.showEditModal.set(false);
}
updateTransaction() {
if (!this.editDescription || !this.editDate) return;
this.api.updateTransaction(this.editId, {
description: this.editDescription,
amount: this.editAmount,
date: this.editDate,
source_account: this.editSourceAccount,
destination_account: this.editDestinationAccount
}).subscribe({
next: () => {
this.loadTransactions();
this.closeEditModal();
},
error: (err) => console.error('Fehler beim Bearbeiten:', err)
});
}
// Delete
openDeleteModal(id: number) {
this.deleteTargetId = id;
this.showDeleteModal.set(true);
}
closeDeleteModal() {
this.showDeleteModal.set(false);
this.deleteTargetId = 0;
}
confirmDelete() {
this.api.deleteTransaction(this.deleteTargetId).subscribe({
next: () => {
this.loadTransactions();
this.closeDeleteModal();
},
error: (err) => console.error('Error deleting transaction:', err)
});
}
accountName(id: any): string {
const acc = this.accounts().find(a => a.id == id);
return acc ? acc.name : (id ?? '—');
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32 16C32 24.8366 24.8366 32 16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16ZM4.5578 16C4.5578 22.3194 9.68065 27.4422 16 27.4422C22.3194 27.4422 27.4422 22.3194 27.4422 16C27.4422 9.68065 22.3194 4.5578 16 4.5578C9.68065 4.5578 4.5578 9.68065 4.5578 16Z" fill="#6200EA"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="248" height="39" viewBox="0 0 248 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32 20C32 28.8366 24.8366 36 16 36C7.16344 36 0 28.8366 0 20C0 11.1634 7.16344 4 16 4C24.8366 4 32 11.1634 32 20ZM4.5578 20C4.5578 26.3194 9.68065 31.4422 16 31.4422C22.3194 31.4422 27.4422 26.3194 27.4422 20C27.4422 13.6806 22.3194 8.5578 16 8.5578C9.68065 8.5578 4.5578 13.6806 4.5578 20Z" fill="#6200EA"/>
<path d="M54.5801 5.79297L64.3008 31H60.6973L58.4648 25.1992H47.0918L44.8594 31H41.2559L50.9766 5.79297H54.5801ZM48.4629 21.5957H57.0762L52.7871 10.4863L48.4629 21.5957ZM88.541 14.793C88.541 18.3086 87.041 20.3125 84.041 20.8047L88.7871 31H84.7969L80.1211 20.9277H72.2285V31H68.625V5.79297H82.4238C86.502 5.79297 88.541 7.83789 88.541 11.9277V14.793ZM72.2285 17.3242H82.2305C83.1914 17.3242 83.8828 17.1133 84.3047 16.6914C84.7266 16.2695 84.9375 15.5781 84.9375 14.6172V12.1035C84.9375 11.1426 84.7266 10.4512 84.3047 10.0293C83.8828 9.60742 83.1914 9.39648 82.2305 9.39648H72.2285V17.3242ZM107.578 31L98.1738 12.5254V31H94.5703V5.79297H98.5078L108.105 24.7773L117.721 5.79297H121.641V31H118.055V12.5254L108.65 31H107.578ZM139.307 5.79297L149.027 31H145.424L143.191 25.1992H131.818L129.586 31H125.982L135.703 5.79297H139.307ZM133.189 21.5957H141.803L137.514 10.4863L133.189 21.5957ZM173.268 14.793C173.268 18.3086 171.768 20.3125 168.768 20.8047L173.514 31H169.523L164.848 20.9277H156.955V31H153.352V5.79297H167.15C171.229 5.79297 173.268 7.83789 173.268 11.9277V14.793ZM156.955 17.3242H166.957C167.918 17.3242 168.609 17.1133 169.031 16.6914C169.453 16.2695 169.664 15.5781 169.664 14.6172V12.1035C169.664 11.1426 169.453 10.4512 169.031 10.0293C168.609 9.60742 167.918 9.39648 166.957 9.39648H156.955V17.3242ZM182.9 5.79297V31H179.297V5.79297H182.9ZM189.387 5.79297H192.99V27.3965H207.387V5.79297H210.99V27.3965C210.99 27.8887 210.896 28.3574 210.709 28.8027C210.521 29.2363 210.264 29.6172 209.936 29.9453C209.607 30.2734 209.221 30.5312 208.775 30.7188C208.342 30.9062 207.879 31 207.387 31H192.99C192.498 31 192.029 30.9062 191.584 30.7188C191.15 30.5312 190.77 30.2734 190.441 29.9453C190.113 29.6172 189.855 29.2363 189.668 28.8027C189.48 28.3574 189.387 27.8887 189.387 27.3965V5.79297ZM230.484 31L221.08 12.5254V31H217.477V5.79297H221.414L231.012 24.7773L240.627 5.79297H244.547V31H240.961V12.5254L231.557 31H230.484Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="208" height="71" viewBox="0 0 208 71" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M113 16C113 24.8366 105.837 32 97 32C88.1634 32 81 24.8366 81 16C81 7.16344 88.1634 0 97 0C105.837 0 113 7.16344 113 16ZM85.5578 16C85.5578 22.3194 90.6806 27.4422 97 27.4422C103.319 27.4422 108.442 22.3194 108.442 16C108.442 9.68065 103.319 4.5578 97 4.5578C90.6806 4.5578 85.5578 9.68065 85.5578 16Z" fill="#6200EA"/>
<path d="M14.5801 37.793L24.3008 63H20.6973L18.4648 57.1992H7.0918L4.85938 63H1.25586L10.9766 37.793H14.5801ZM8.46289 53.5957H17.0762L12.7871 42.4863L8.46289 53.5957ZM48.541 46.793C48.541 50.3086 47.041 52.3125 44.041 52.8047L48.7871 63H44.7969L40.1211 52.9277H32.2285V63H28.625V37.793H42.4238C46.502 37.793 48.541 39.8379 48.541 43.9277V46.793ZM32.2285 49.3242H42.2305C43.1914 49.3242 43.8828 49.1133 44.3047 48.6914C44.7266 48.2695 44.9375 47.5781 44.9375 46.6172V44.1035C44.9375 43.1426 44.7266 42.4512 44.3047 42.0293C43.8828 41.6074 43.1914 41.3965 42.2305 41.3965H32.2285V49.3242ZM67.5781 63L58.1738 44.5254V63H54.5703V37.793H58.5078L68.1055 56.7773L77.7207 37.793H81.6406V63H78.0547V44.5254L68.6504 63H67.5781ZM99.3066 37.793L109.027 63H105.424L103.191 57.1992H91.8184L89.5859 63H85.9824L95.7031 37.793H99.3066ZM93.1895 53.5957H101.803L97.5137 42.4863L93.1895 53.5957ZM133.268 46.793C133.268 50.3086 131.768 52.3125 128.768 52.8047L133.514 63H129.523L124.848 52.9277H116.955V63H113.352V37.793H127.15C131.229 37.793 133.268 39.8379 133.268 43.9277V46.793ZM116.955 49.3242H126.957C127.918 49.3242 128.609 49.1133 129.031 48.6914C129.453 48.2695 129.664 47.5781 129.664 46.6172V44.1035C129.664 43.1426 129.453 42.4512 129.031 42.0293C128.609 41.6074 127.918 41.3965 126.957 41.3965H116.955V49.3242ZM142.9 37.793V63H139.297V37.793H142.9ZM149.387 37.793H152.99V59.3965H167.387V37.793H170.99V59.3965C170.99 59.8887 170.896 60.3574 170.709 60.8027C170.521 61.2363 170.264 61.6172 169.936 61.9453C169.607 62.2734 169.221 62.5312 168.775 62.7188C168.342 62.9062 167.879 63 167.387 63H152.99C152.498 63 152.029 62.9062 151.584 62.7188C151.15 62.5312 150.77 62.2734 150.441 61.9453C150.113 61.6172 149.855 61.2363 149.668 60.8027C149.48 60.3574 149.387 59.8887 149.387 59.3965V37.793ZM190.484 63L181.08 44.5254V63H177.477V37.793H181.414L191.012 56.7773L200.627 37.793H204.547V63H200.961V44.5254L191.557 63H190.484Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+404
View File
@@ -0,0 +1,404 @@
{
"common": {
"save_changes": "Änderungen speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"add": "Hinzufügen",
"close": "Schliessen",
"save": "Speichern",
"name": "Name",
"optional": "optional",
"delete_confirm_title": "Eintrag löschen?",
"delete_confirm_text": "Diese Aktion kann nicht rückgängig gemacht werden.",
"no_accounts_title": "Kein Konto vorhanden",
"no_accounts_text": "Um einen Eintrag zu erstellen, muss zuerst ein Konto angelegt werden.",
"go_to_accounts": "Zu den Konten"
},
"auth": {
"sign_in": "Anmelden",
"sign_up": "Registrieren",
"signing_in": "Anmeldung läuft...",
"creating_account": "Konto wird erstellt...",
"username": "Benutzername",
"email": "E-Mail",
"password": "Passwort",
"confirm_password": "Passwort bestätigen",
"tagline_login": "Bei deinem Konto anmelden",
"tagline_register": "Konto erstellen",
"no_account": "Noch kein Konto?",
"create_account": "Konto erstellen",
"has_account": "Bereits ein Konto?",
"password_hint": "Mindestens 8 Zeichen.",
"username_hint": "Wird in der App als Anzeigename verwendet.",
"totp_title": "2-Faktor-Authentifizierung",
"totp_hint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"totp_or_backup": "Oder gib einen Backup-Code ein.",
"totp_no_device": "Ich habe mein 2FA-Gerät nicht",
"totp_use_backup": "Sicherungs-Wiederherstellungscode verwenden",
"totp_no_backup": "Ich habe auch keinen Wiederherstellungscode",
"backup_format_hint": "Format: XXXXXXXX-XXXXXXXX",
"recovery_title": "Konto-Wiederherstellung",
"recovery_intro": "Um dein Konto sicher zu halten, möchten wir sicherstellen, dass du es wirklich bist. Ein Bestätigungscode wird an deine hinterlegte Wiederherstellungs-E-Mail gesendet.",
"recovery_send": "Bestätigungscode senden",
"recovery_sent": "Eine E-Mail mit einem Bestätigungscode wurde gerade gesendet an:",
"recovery_spam_hint": "Wenn du die Nachricht nicht in deinem Posteingang findest, überprüfe bitte deinen Spam-Ordner.",
"recovery_confirm": "Code bestätigen",
"recovery_verifying": "Link wird überprüft…",
"recovery_success": "2FA deaktiviert. Du wirst weitergeleitet…",
"recovery_redirecting": "Weiterleitung zum Dashboard…",
"recovery_error": "Der Link ist ungültig oder abgelaufen.",
"keep_signed_in": "Angemeldet bleiben",
"keep_signed_in_hint": "Auf vertrauenswürdigen Geräten empfohlen.",
"back_to_login": "Zurück zur Anmeldung",
"errors": {
"fields_required": "Alle Felder sind erforderlich.",
"passwords_mismatch": "Passwörter stimmen nicht überein.",
"password_too_short": "Passwort muss mindestens 8 Zeichen lang sein.",
"invalid_credentials": "Ungültiger Benutzername oder Passwort.",
"enter_credentials": "Bitte Benutzername und Passwort eingeben.",
"registration_failed": "Registrierung fehlgeschlagen.",
"invalid_totp": "Ungültiger oder abgelaufener Code.",
"captcha_failed": "Sicherheitsüberprüfung fehlgeschlagen. Bitte versuche es erneut.",
"reset_failed": "Ungültiger oder abgelaufener Link.",
"token_missing": "Kein Token gefunden.",
"verify_failed": "Ungültiger oder abgelaufener Bestätigungslink."
},
"forgot_password": "Passwort vergessen?",
"forgot_password_tagline": "Passwort zurücksetzen",
"forgot_password_hint": "Gib deine E-Mail-Adresse ein. Falls ein Konto existiert, senden wir dir einen Reset-Link.",
"send_reset_link": "Reset-Link senden",
"sending": "Wird gesendet...",
"reset_link_sent": "Link gesendet",
"reset_link_sent_hint": "Falls ein Konto mit dieser Adresse existiert, hast du eine E-Mail erhalten. Überprüfe auch deinen Spam-Ordner.",
"reset_password": "Passwort zurücksetzen",
"reset_password_tagline": "Neues Passwort festlegen",
"reset_password_hint": "Gib dein neues Passwort ein.",
"new_password": "Neues Passwort",
"resetting": "Wird gesetzt...",
"reset_success": "Passwort erfolgreich geändert.",
"verifying": "E-Mail-Adresse wird bestätigt...",
"email_verified": "E-Mail-Adresse bestätigt.",
"verify_email_error": "Bestätigung fehlgeschlagen"
},
"settings": {
"subtitle": "Sicherheit und Account-Einstellungen",
"recovery_email": "Wiederherstellungs-E-Mail",
"recovery_email_hint": "Diese E-Mail wird für die 2FA-Wiederherstellung verwendet. Verwende eine andere Adresse als deine Login-E-Mail.",
"recovery_email_saved": "E-Mail gespeichert.",
"sessions_title": "Aktive Sitzungen",
"sessions_hint": "Alle Geräte, auf denen du aktuell angemeldet bist.",
"sessions_current": "Aktuelle Sitzung",
"sessions_revoke": "Abmelden",
"sessions_revoke_all": "Alle anderen abmelden",
"sessions_loading": "Sitzungen werden geladen…",
"export_title": "Daten exportieren",
"export_hint": "Lade einen ZIP-Ordner mit allen deinen Daten als PDF herunter.",
"export_btn": "Export herunterladen",
"export_loading": "Export wird erstellt…",
"notif_title": "Benachrichtigungen",
"notif_hint": "Wähle, worüber du informiert werden möchtest.",
"notif_deadlines": "Anstehende Termine",
"notif_deadlines_hint": "Erinnerung bei bevorstehenden Terminen.",
"notif_budget_alerts": "Budget-Warnungen",
"notif_budget_alerts_hint": "Benachrichtigung wenn ein Budget überschritten wird.",
"notif_monthly_summary": "Monatliche Zusammenfassung",
"notif_monthly_summary_hint": "Übersicht über Einnahmen und Ausgaben am Monatsende.",
"notif_saved": "Einstellungen gespeichert.",
"delete_step1_title": "Daten zuerst exportieren",
"delete_step1_hint": "Lade deine Daten herunter, bevor du das Konto löschst. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_step1_btn": "Daten exportieren",
"delete_step2_exported": "Export heruntergeladen",
"delete_password_label": "Passwort bestätigen",
"delete_phrase_label": "Tippe zur Bestätigung:",
"delete_wrong_password": "Passwort ist falsch.",
"sessions_unknown_device": "Unbekanntes Gerät"
},
"nav": {
"search_placeholder": "Suchen...",
"no_results": "Keine Ergebnisse gefunden.",
"notifications": "Benachrichtigungen",
"no_notifications": "Keine neuen Benachrichtigungen.",
"mark_read": "Als gelesen markieren",
"mark_all_read": "Alle als gelesen markieren",
"more_coming_soon": "Weitere Funktionen folgen",
"sign_out": "Abmelden",
"profile": "Profil",
"settings": "Einstellungen",
"dark_mode": "Dunkelmodus",
"light_mode": "Hellmodus"
},
"search": {
"accounts": "Konten",
"budgets": "Budgets",
"expenses": "Ausgaben",
"transactions": "Transaktionen",
"deadlines": "Termine"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budgets",
"fixed_costs": "Fixkosten",
"expenses": "Ausgaben",
"calendar": "Kalender",
"accounts": "Konten",
"revenue_accounts": "Einnahmekonten",
"transactions": "Transaktionen"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Finanzübersicht",
"total_income": "Gesamteinnahmen",
"fixed_costs": "Fixkosten",
"expenses": "Ausgaben",
"balance": "Saldo",
"per_month": "CHF / Monat",
"chf_total": "CHF gesamt",
"chf_remaining": "CHF verbleibend",
"income_vs_expenses": "Einnahmen vs. Ausgaben {{ year }}",
"fixed_costs_breakdown": "Fixkostenaufschlüsselung",
"savings_rate": "Sparquote",
"of_income": "des Einkommens",
"goal": "Sparziel",
"goal_hint": "Empfohlen: mind. 20% des Einkommens sparen.",
"recent_expenses": "Letzte Ausgaben",
"no_expenses": "Noch keine Ausgaben erfasst.",
"series_income": "Einnahmen",
"series_fixed_costs": "Fixkosten",
"series_expenses": "Variable Ausgaben",
"view_report": "Bericht anzeigen",
"greeting_morning": "Guten Morgen",
"greeting_afternoon": "Guten Tag",
"greeting_evening": "Guten Abend",
"greeting_night": "Gute Nacht"
},
"accounts": {
"title": "Alle Konten",
"add": "Konto hinzufügen",
"col_type": "Typ",
"col_balance": "Kontostand (CHF)",
"no_accounts": "Noch keine Konten vorhanden.",
"create_title": "Neues Konto erstellen",
"edit_title": "Konto bearbeiten",
"label_balance": "Kontostand (CHF)",
"label_type": "Kontotyp",
"type_asset": "Vermögen",
"type_revenue": "Einnahmequelle",
"placeholder_name": "z.B. Sparkonto"
},
"budgets": {
"title": "Fixkosten & Budget",
"subtitle": "Gesamtausgaben: ",
"add": "Hinzufügen",
"new_entry": "Neuer Eintrag — {{ category }}",
"edit_entry": "Eintrag bearbeiten",
"label_amount": "Betrag (CHF)",
"label_category": "Kategorie",
"label_account": "Konto",
"label_active": "Aktiv",
"label_suggestions": "Vorschläge",
"no_entries": "Noch keine Einträge in dieser Kategorie.",
"entries_count": "({{ count }} Einträge)",
"placeholder_name": "z.B. Miete",
"categories": {
"fixed_expenses": "Fixe Ausgaben",
"mobile_internet": "Mobile & Internet",
"subscriptions": "Abonnements",
"leisure": "Freizeit",
"tax_reserves": "Steuerrücklagen",
"insurance": "Versicherungen",
"loans": "Abzahlungen & Kredite"
}
},
"expenses": {
"title": "Ausgaben",
"total": "Total:",
"add": "Ausgabe hinzufügen",
"col_date": "Datum",
"col_name": "Name",
"col_category": "Kategorie",
"col_account": "Konto",
"col_amount": "Betrag",
"no_expenses": "Noch keine Ausgaben erfasst.",
"create_title": "Ausgabe hinzufügen",
"edit_title": "Ausgabe bearbeiten",
"label_amount": "Betrag (CHF)",
"label_date": "Datum",
"label_category": "Kategorie",
"label_account": "Konto",
"label_due_date": "Fälligkeitsdatum",
"label_notes": "Notizen",
"placeholder_name": "z.B. Migros",
"categories": {
"groceries": "Lebensmittel",
"dining": "Restaurant & Essen",
"transport": "Transport",
"health": "Gesundheit & Medizin",
"clothing": "Kleidung",
"electronics": "Elektronik",
"household": "Haushalt",
"entertainment": "Unterhaltung",
"travel": "Reisen",
"other": "Sonstiges"
}
},
"transactions": {
"title": "Alle Transaktionen",
"add": "Transaktion hinzufügen",
"col_date": "Datum",
"col_description": "Beschreibung",
"col_from": "Von",
"col_to": "Nach",
"col_amount": "Betrag",
"no_transactions": "Noch keine Transaktionen vorhanden.",
"create_title": "Neue Transaktion erstellen",
"edit_title": "Transaktion bearbeiten",
"label_description": "Beschreibung",
"label_amount": "Betrag (CHF)",
"label_date": "Datum",
"label_from": "Von Konto",
"label_to": "Nach Konto",
"select_account": "Konto wählen...",
"placeholder_description": "z.B. Miete Januar"
},
"calendar": {
"year_view": "Jahresansicht",
"subscribe": "Abonnieren",
"ical_title": "Deine iCal-Feed-URL",
"ical_desc": "Füge diese URL in jede Kalender-App ein (Protonmail, Google, Apple, Outlook).",
"ical_copy": "Kopieren",
"ical_copied": "Kopiert!",
"filter_holidays": "Feiertage",
"filter_school": "Schulferien",
"filter_invoices": "Rechnungen",
"filter_deadlines": "Termine",
"add_deadline": "Termin hinzufügen",
"select_type": "Grund wählen...",
"label_title": "Titel",
"label_date": "Datum",
"label_type": "Typ",
"label_notes": "Notizen",
"deadline_types": {
"tax": "Steuer",
"insurance": "Versicherung",
"invoice": "Rechnung",
"personal": "Persönlich",
"other": "Sonstiges"
},
"months": {
"1": "Januar",
"2": "Februar",
"3": "März",
"4": "April",
"5": "Mai",
"6": "Juni",
"7": "Juli",
"8": "August",
"9": "September",
"10": "Oktober",
"11": "November",
"12": "Dezember"
},
"weekdays": [
"Mo",
"Di",
"Mi",
"Do",
"Fr",
"Sa",
"So"
],
"no_events": "Keine Ereignisse an diesem Tag.",
"more_events": "+{{ count }} weitere",
"legend_national": "Nationaler Feiertag",
"legend_cantonal": "Kantonaler Feiertag",
"legend_school": "Schulferien",
"legend_expense": "Zahlungstermin",
"legend_personal": "Persönlicher Termin"
},
"canton_names": {
"AG": "Aargau",
"AI": "Appenzell Innerrhoden",
"AR": "Appenzell Ausserrhoden",
"BE": "Bern",
"BL": "Basel-Landschaft",
"BS": "Basel-Stadt",
"FR": "Freiburg",
"GE": "Genf",
"GL": "Glarus",
"GR": "Graubünden",
"JU": "Jura",
"LU": "Luzern",
"NE": "Neuenburg",
"NW": "Nidwalden",
"OW": "Obwalden",
"SG": "St. Gallen",
"SH": "Schaffhausen",
"SO": "Solothurn",
"SZ": "Schwyz",
"TG": "Thurgau",
"TI": "Tessin",
"UR": "Uri",
"VD": "Waadt",
"VS": "Wallis",
"ZG": "Zug",
"ZH": "Zürich"
},
"profile": {
"title": "Profil",
"subtitle": "Persönliche Informationen und Einstellungen verwalten",
"personal_info": "Persönliche Informationen",
"profile_photo": "Profilfoto",
"photo_hint": "Klicke auf den Avatar, um ein Foto hochzuladen",
"fallback_color": "Ersatzfarbe",
"first_name": "Vorname",
"last_name": "Nachname",
"email": "E-Mail",
"canton": "Kanton",
"language": "Sprache",
"save_changes": "Änderungen speichern",
"save_success": "Profil erfolgreich gespeichert.",
"change_password": "Passwort ändern",
"new_password": "Neues Passwort",
"confirm_password": "Passwort bestätigen",
"update_password": "Passwort aktualisieren",
"password_success": "Passwort erfolgreich aktualisiert.",
"totp_title": "Zwei-Faktor-Authentifizierung",
"totp_subtitle": "Schütze deinen Account mit einer Authenticator-App. Empfohlen: Proton Pass, Aegis (Android) oder Raivo OTP (iOS).",
"totp_on": "Aktiviert",
"totp_off": "Deaktiviert",
"totp_enable": "2FA aktivieren",
"totp_disable": "2FA deaktivieren",
"totp_scan_hint": "Scanne den QR-Code mit deiner Authenticator-App und gib dann den 6-stelligen Code ein.",
"totp_disable_hint": "Gib den aktuellen Code aus deiner Authenticator-App ein, um 2FA zu deaktivieren.",
"totp_code_label": "Bestätigungscode",
"totp_confirm": "Bestätigen & aktivieren",
"totp_enabled_success": "2FA erfolgreich aktiviert.",
"totp_disabled_success": "2FA wurde deaktiviert.",
"totp_invalid_code": "Ungültiger Code. Bitte versuche es erneut.",
"backup_codes_title": "Backup-Codes speichern",
"backup_codes_hint": "Diese Codes werden nur einmal angezeigt. Sichere sie jetzt.",
"backup_copy": "Kopieren",
"backup_copied": "Kopiert!",
"backup_download_pdf": "Als PDF herunterladen",
"backup_saved": "Ich habe sie gesichert",
"danger_zone": "Gefahrenzone",
"danger_text": "Dein Konto und alle zugehörigen Daten werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account": "Konto löschen",
"delete_account_confirm": "Konto löschen?",
"delete_account_text": "Alle deine Daten werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.",
"languages": {
"de": "Deutsch",
"fr": "Français",
"it": "Italiano",
"en": "English"
},
"errors": {
"password_empty": "Passwort darf nicht leer sein.",
"passwords_mismatch": "Passwörter stimmen nicht überein.",
"password_too_short": "Passwort muss mindestens 8 Zeichen lang sein.",
"password_failed": "Passwort konnte nicht aktualisiert werden."
}
}
}
+404
View File
@@ -0,0 +1,404 @@
{
"common": {
"save_changes": "Save Changes",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"add": "Add",
"close": "Close",
"save": "Save",
"name": "Name",
"optional": "optional",
"delete_confirm_title": "Delete entry?",
"delete_confirm_text": "This action cannot be undone.",
"no_accounts_title": "No Account Found",
"no_accounts_text": "To create an entry, you need to set up an account first.",
"go_to_accounts": "Go to Accounts"
},
"auth": {
"sign_in": "Sign In",
"sign_up": "Sign Up",
"signing_in": "Signing in...",
"creating_account": "Creating account...",
"username": "Username",
"email": "Email",
"password": "Password",
"confirm_password": "Confirm Password",
"tagline_login": "Sign in to your account",
"tagline_register": "Create your account",
"no_account": "Don't have an account?",
"create_account": "Create an account",
"has_account": "Already have an account?",
"password_hint": "At least 8 characters.",
"username_hint": "Used as your display name within the app.",
"totp_title": "Two-Factor Authentication",
"totp_hint": "Enter the 6-digit code from your authenticator app.",
"totp_or_backup": "Or enter a backup code.",
"totp_no_device": "I don't have my 2FA device",
"totp_use_backup": "Use security recovery code",
"totp_no_backup": "I don't have a recovery code either",
"backup_format_hint": "Format: XXXXXXXX-XXXXXXXX",
"recovery_title": "Account Recovery",
"recovery_intro": "To keep your account secure, we want to make sure it's really you. A confirmation code will be sent to your registered recovery email.",
"recovery_send": "Send confirmation code",
"recovery_sent": "An email with a confirmation code has just been sent to:",
"recovery_spam_hint": "If you don't find the message in your inbox, please check your spam folder.",
"recovery_confirm": "Confirm code",
"recovery_verifying": "Verifying link…",
"recovery_success": "2FA disabled. Redirecting…",
"recovery_redirecting": "Redirecting to dashboard…",
"recovery_error": "The link is invalid or has expired.",
"keep_signed_in": "Keep me signed in",
"keep_signed_in_hint": "Recommended on trusted devices.",
"back_to_login": "Back to login",
"errors": {
"fields_required": "All fields are required.",
"passwords_mismatch": "Passwords do not match.",
"password_too_short": "Password must be at least 8 characters.",
"invalid_credentials": "Invalid username or password.",
"enter_credentials": "Please enter username and password.",
"registration_failed": "Registration failed.",
"invalid_totp": "Invalid or expired code.",
"captcha_failed": "Security check failed. Please try again.",
"reset_failed": "Invalid or expired link.",
"token_missing": "No token found.",
"verify_failed": "Invalid or expired verification link."
},
"forgot_password": "Forgot password?",
"forgot_password_tagline": "Reset password",
"forgot_password_hint": "Enter your email address. If an account exists, we'll send you a reset link.",
"send_reset_link": "Send reset link",
"sending": "Sending...",
"reset_link_sent": "Link sent",
"reset_link_sent_hint": "If an account with this address exists, you'll receive an email. Check your spam folder too.",
"reset_password": "Reset password",
"reset_password_tagline": "Set new password",
"reset_password_hint": "Enter your new password.",
"new_password": "New password",
"resetting": "Resetting...",
"reset_success": "Password changed successfully.",
"verifying": "Verifying your email address...",
"email_verified": "Email address verified.",
"verify_email_error": "Verification failed"
},
"settings": {
"subtitle": "Security and account settings",
"recovery_email": "Recovery Email",
"recovery_email_hint": "This email is used for 2FA recovery. Use a different address than your login email.",
"recovery_email_saved": "Email saved.",
"sessions_title": "Active Sessions",
"sessions_hint": "All devices where you are currently signed in.",
"sessions_current": "Current session",
"sessions_revoke": "Sign out",
"sessions_revoke_all": "Sign out all others",
"sessions_loading": "Loading sessions…",
"export_title": "Export Data",
"export_hint": "Download a ZIP folder with all your data as PDF files.",
"export_btn": "Download Export",
"export_loading": "Preparing export…",
"notif_title": "Notifications",
"notif_hint": "Choose what you'd like to be notified about.",
"notif_deadlines": "Upcoming Deadlines",
"notif_deadlines_hint": "Reminder for upcoming appointments and deadlines.",
"notif_budget_alerts": "Budget Alerts",
"notif_budget_alerts_hint": "Notification when a budget limit is exceeded.",
"notif_monthly_summary": "Monthly Summary",
"notif_monthly_summary_hint": "Overview of income and expenses at the end of the month.",
"notif_saved": "Settings saved.",
"delete_step1_title": "Export your data first",
"delete_step1_hint": "Download your data before deleting your account. This action cannot be undone.",
"delete_step1_btn": "Export data",
"delete_step2_exported": "Export downloaded",
"delete_password_label": "Confirm your password",
"delete_phrase_label": "Type to confirm:",
"delete_wrong_password": "Password is incorrect.",
"sessions_unknown_device": "Unknown Device"
},
"nav": {
"search_placeholder": "Search...",
"no_results": "No results found.",
"notifications": "Notifications",
"no_notifications": "No new notifications.",
"mark_read": "Mark as read",
"mark_all_read": "Mark all as read",
"more_coming_soon": "More features coming soon",
"sign_out": "Sign out",
"profile": "Profile",
"settings": "Settings",
"dark_mode": "Dark Mode",
"light_mode": "Light Mode"
},
"search": {
"accounts": "Accounts",
"budgets": "Budgets",
"expenses": "Expenses",
"transactions": "Transactions",
"deadlines": "Deadlines"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budgets",
"fixed_costs": "Fixed Costs",
"expenses": "Expenses",
"calendar": "Calendar",
"accounts": "Accounts",
"revenue_accounts": "Revenue Accounts",
"transactions": "Transactions"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Financial overview",
"total_income": "Total Income",
"fixed_costs": "Fixed Costs",
"expenses": "Expenses",
"balance": "Balance",
"per_month": "CHF / month",
"chf_total": "CHF total",
"chf_remaining": "CHF remaining",
"income_vs_expenses": "Income vs. Expenses {{ year }}",
"fixed_costs_breakdown": "Fixed Costs Breakdown",
"savings_rate": "Savings Rate",
"of_income": "of income",
"goal": "Savings Goal",
"goal_hint": "Recommended: save at least 20% of your income.",
"recent_expenses": "Recent Expenses",
"no_expenses": "No expenses recorded yet.",
"series_income": "Income",
"series_fixed_costs": "Fixed Costs",
"series_expenses": "Variable Expenses",
"view_report": "View Report",
"greeting_morning": "Good Morning",
"greeting_afternoon": "Good Afternoon",
"greeting_evening": "Good Evening",
"greeting_night": "Good Night"
},
"accounts": {
"title": "All Accounts",
"add": "Add Account",
"col_type": "Type",
"col_balance": "Balance (CHF)",
"no_accounts": "No accounts yet.",
"create_title": "Create Account",
"edit_title": "Edit Account",
"label_balance": "Balance (CHF)",
"label_type": "Account Type",
"type_asset": "Asset",
"type_revenue": "Revenue",
"placeholder_name": "e.g. Savings account"
},
"budgets": {
"title": "Fixed Costs & Budget",
"subtitle": "Total expenses: ",
"add": "Add",
"new_entry": "New Entry — {{ category }}",
"edit_entry": "Edit Entry",
"label_amount": "Amount (CHF)",
"label_category": "Category",
"label_account": "Account",
"label_active": "Active",
"label_suggestions": "Suggestions",
"no_entries": "No entries in this category yet.",
"entries_count": "({{ count }} entries)",
"placeholder_name": "e.g. Rent",
"categories": {
"fixed_expenses": "Fixed Expenses",
"mobile_internet": "Mobile & Internet",
"subscriptions": "Subscriptions",
"leisure": "Leisure",
"tax_reserves": "Tax Reserves",
"insurance": "Insurance",
"loans": "Loans & Credits"
}
},
"expenses": {
"title": "Expenses",
"total": "Total:",
"add": "Add Expense",
"col_date": "Date",
"col_name": "Name",
"col_category": "Category",
"col_account": "Account",
"col_amount": "Amount",
"no_expenses": "No expenses recorded yet.",
"create_title": "Add Expense",
"edit_title": "Edit Expense",
"label_amount": "Amount (CHF)",
"label_date": "Date",
"label_category": "Category",
"label_account": "Account",
"label_due_date": "Payment Due Date",
"label_notes": "Notes",
"placeholder_name": "e.g. Migros",
"categories": {
"groceries": "Groceries",
"dining": "Dining & Restaurants",
"transport": "Transport",
"health": "Health & Medical",
"clothing": "Clothing",
"electronics": "Electronics",
"household": "Household",
"entertainment": "Entertainment",
"travel": "Travel",
"other": "Other"
}
},
"transactions": {
"title": "All Transactions",
"add": "Add Transaction",
"col_date": "Date",
"col_description": "Description",
"col_from": "From",
"col_to": "To",
"col_amount": "Amount",
"no_transactions": "No transactions yet.",
"create_title": "Create Transaction",
"edit_title": "Edit Transaction",
"label_description": "Description",
"label_amount": "Amount (CHF)",
"label_date": "Date",
"label_from": "From Account",
"label_to": "To Account",
"select_account": "Select account...",
"placeholder_description": "e.g. January Rent"
},
"calendar": {
"year_view": "Year view",
"subscribe": "Subscribe",
"ical_title": "Your iCal Feed URL",
"ical_desc": "Add this URL to any calendar app (Protonmail, Google, Apple, Outlook).",
"ical_copy": "Copy",
"ical_copied": "Copied!",
"filter_holidays": "Holidays",
"filter_school": "School Holidays",
"filter_invoices": "Invoices",
"filter_deadlines": "Deadlines",
"add_deadline": "Add Deadline",
"select_type": "Select reason...",
"label_title": "Title",
"label_date": "Date",
"label_type": "Type",
"label_notes": "Notes",
"deadline_types": {
"tax": "Tax",
"insurance": "Insurance",
"invoice": "Invoice",
"personal": "Personal",
"other": "Other"
},
"months": {
"1": "January",
"2": "February",
"3": "March",
"4": "April",
"5": "May",
"6": "June",
"7": "July",
"8": "August",
"9": "September",
"10": "October",
"11": "November",
"12": "December"
},
"weekdays": [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
],
"no_events": "No events on this day.",
"more_events": "+{{ count }} more",
"legend_national": "National Holiday",
"legend_cantonal": "Cantonal Holiday",
"legend_school": "School Holiday",
"legend_expense": "Expense Due Date",
"legend_personal": "Personal Deadline"
},
"canton_names": {
"AG": "Aargau",
"AI": "Appenzell Inner Rhodes",
"AR": "Appenzell Outer Rhodes",
"BE": "Bern",
"BL": "Basel-Landschaft",
"BS": "Basel-Stadt",
"FR": "Fribourg",
"GE": "Geneva",
"GL": "Glarus",
"GR": "Graubünden",
"JU": "Jura",
"LU": "Lucerne",
"NE": "Neuchâtel",
"NW": "Nidwalden",
"OW": "Obwalden",
"SG": "St. Gallen",
"SH": "Schaffhausen",
"SO": "Solothurn",
"SZ": "Schwyz",
"TG": "Thurgau",
"TI": "Ticino",
"UR": "Uri",
"VD": "Vaud",
"VS": "Valais",
"ZG": "Zug",
"ZH": "Zuerich"
},
"profile": {
"title": "Profile",
"subtitle": "Manage your personal information and settings",
"personal_info": "Personal Information",
"profile_photo": "Profile Photo",
"photo_hint": "Click on the avatar to upload a photo",
"fallback_color": "Fallback Color",
"first_name": "First Name",
"last_name": "Last Name",
"email": "Email",
"canton": "Canton",
"language": "Language",
"save_changes": "Save Changes",
"save_success": "Profile saved successfully.",
"change_password": "Change Password",
"new_password": "New Password",
"confirm_password": "Confirm Password",
"update_password": "Update Password",
"password_success": "Password updated successfully.",
"totp_title": "Two-Factor Authentication",
"totp_subtitle": "Protect your account with an authenticator app. Recommended: Proton Pass, Aegis (Android) or Raivo OTP (iOS).",
"totp_on": "Enabled",
"totp_off": "Disabled",
"totp_enable": "Enable 2FA",
"totp_disable": "Disable 2FA",
"totp_scan_hint": "Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.",
"totp_disable_hint": "Enter the current code from your authenticator app to disable 2FA.",
"totp_code_label": "Confirmation code",
"totp_confirm": "Confirm & enable",
"totp_enabled_success": "2FA successfully enabled.",
"totp_disabled_success": "2FA has been disabled.",
"totp_invalid_code": "Invalid code. Please try again.",
"backup_codes_title": "Save your backup codes",
"backup_codes_hint": "These codes are shown only once. Save them somewhere safe.",
"backup_copy": "Copy",
"backup_copied": "Copied!",
"backup_download_pdf": "Download as PDF",
"backup_saved": "I've saved them",
"danger_zone": "Danger Zone",
"danger_text": "Permanently delete your account and all associated data. This action cannot be undone.",
"delete_account": "Delete Account",
"delete_account_confirm": "Delete Account?",
"delete_account_text": "All your data will be permanently deleted. This cannot be undone.",
"languages": {
"de": "Deutsch",
"fr": "Français",
"it": "Italiano",
"en": "English"
},
"errors": {
"password_empty": "Password cannot be empty.",
"passwords_mismatch": "Passwords do not match.",
"password_too_short": "Password must be at least 8 characters.",
"password_failed": "Failed to update password."
}
}
}
+404
View File
@@ -0,0 +1,404 @@
{
"common": {
"save_changes": "Enregistrer les modifications",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"create": "Créer",
"add": "Ajouter",
"close": "Fermer",
"save": "Enregistrer",
"name": "Nom",
"optional": "optionnel",
"delete_confirm_title": "Supprimer l'entrée ?",
"delete_confirm_text": "Cette action est irréversible.",
"no_accounts_title": "Aucun compte disponible",
"no_accounts_text": "Pour créer une entrée, vous devez d'abord configurer un compte.",
"go_to_accounts": "Aller aux comptes"
},
"auth": {
"sign_in": "Se connecter",
"sign_up": "S'inscrire",
"signing_in": "Connexion en cours...",
"creating_account": "Création du compte...",
"username": "Nom d'utilisateur",
"email": "E-mail",
"password": "Mot de passe",
"confirm_password": "Confirmer le mot de passe",
"tagline_login": "Connectez-vous à votre compte",
"tagline_register": "Créez votre compte",
"no_account": "Pas encore de compte ?",
"create_account": "Créer un compte",
"has_account": "Déjà un compte ?",
"password_hint": "Au moins 8 caractères.",
"username_hint": "Utilisé comme nom d'affichage dans l'application.",
"totp_title": "Authentification à deux facteurs",
"totp_hint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
"totp_or_backup": "Ou saisissez un code de secours.",
"totp_no_device": "Je n'ai pas mon appareil 2FA",
"totp_use_backup": "Utiliser un code de récupération",
"totp_no_backup": "Je n'ai pas non plus de code de récupération",
"backup_format_hint": "Format : XXXXXXXX-XXXXXXXX",
"recovery_title": "Récupération du compte",
"recovery_intro": "Pour sécuriser votre compte, nous voulons nous assurer que c'est bien vous. Un code de confirmation sera envoyé à votre adresse e-mail de récupération enregistrée.",
"recovery_send": "Envoyer le code de confirmation",
"recovery_sent": "Un e-mail avec un code de confirmation vient d'être envoyé à :",
"recovery_spam_hint": "Si vous ne trouvez pas le message dans votre boîte de réception, vérifiez votre dossier spam.",
"recovery_confirm": "Confirmer le code",
"recovery_verifying": "Vérification du lien…",
"recovery_success": "2FA désactivé. Redirection en cours…",
"recovery_redirecting": "Redirection vers le tableau de bord…",
"recovery_error": "Le lien est invalide ou a expiré.",
"keep_signed_in": "Rester connecté",
"keep_signed_in_hint": "Recommandé sur les appareils de confiance.",
"back_to_login": "Retour à la connexion",
"errors": {
"fields_required": "Tous les champs sont obligatoires.",
"passwords_mismatch": "Les mots de passe ne correspondent pas.",
"password_too_short": "Le mot de passe doit comporter au moins 8 caractères.",
"invalid_credentials": "Nom d'utilisateur ou mot de passe invalide.",
"enter_credentials": "Veuillez saisir votre nom d'utilisateur et votre mot de passe.",
"registration_failed": "L'inscription a échoué.",
"invalid_totp": "Code invalide ou expiré.",
"captcha_failed": "La vérification de sécurité a échoué. Veuillez réessayer.",
"reset_failed": "Lien invalide ou expiré.",
"token_missing": "Aucun token trouvé.",
"verify_failed": "Lien de vérification invalide ou expiré."
},
"forgot_password": "Mot de passe oublié ?",
"forgot_password_tagline": "Réinitialiser le mot de passe",
"forgot_password_hint": "Saisissez votre adresse e-mail. Si un compte existe, nous vous enverrons un lien de réinitialisation.",
"send_reset_link": "Envoyer le lien",
"sending": "Envoi en cours...",
"reset_link_sent": "Lien envoyé",
"reset_link_sent_hint": "Si un compte avec cette adresse existe, vous recevrez un e-mail. Vérifiez également vos spams.",
"reset_password": "Réinitialiser le mot de passe",
"reset_password_tagline": "Définir un nouveau mot de passe",
"reset_password_hint": "Saisissez votre nouveau mot de passe.",
"new_password": "Nouveau mot de passe",
"resetting": "Réinitialisation...",
"reset_success": "Mot de passe modifié avec succès.",
"verifying": "Vérification de votre adresse e-mail...",
"email_verified": "Adresse e-mail vérifiée.",
"verify_email_error": "Échec de la vérification"
},
"settings": {
"subtitle": "Sécurité et paramètres du compte",
"recovery_email": "E-mail de récupération",
"recovery_email_hint": "Cet e-mail est utilisé pour la récupération 2FA. Utilisez une adresse différente de votre e-mail de connexion.",
"recovery_email_saved": "E-mail enregistré.",
"sessions_title": "Sessions actives",
"sessions_hint": "Tous les appareils sur lesquels vous êtes actuellement connecté.",
"sessions_current": "Session actuelle",
"sessions_revoke": "Déconnecter",
"sessions_revoke_all": "Déconnecter toutes les autres",
"sessions_loading": "Chargement des sessions…",
"export_title": "Exporter les données",
"export_hint": "Téléchargez un dossier ZIP avec toutes vos données en PDF.",
"export_btn": "Télécharger l'export",
"export_loading": "Préparation de l'export…",
"notif_title": "Notifications",
"notif_hint": "Choisissez ce dont vous souhaitez être informé.",
"notif_deadlines": "Échéances à venir",
"notif_deadlines_hint": "Rappel pour les rendez-vous et délais à venir.",
"notif_budget_alerts": "Alertes budget",
"notif_budget_alerts_hint": "Notification lorsqu'un budget est dépassé.",
"notif_monthly_summary": "Résumé mensuel",
"notif_monthly_summary_hint": "Aperçu des revenus et dépenses en fin de mois.",
"notif_saved": "Paramètres enregistrés.",
"delete_step1_title": "Exportez d'abord vos données",
"delete_step1_hint": "Téléchargez vos données avant de supprimer votre compte. Cette action est irréversible.",
"delete_step1_btn": "Exporter les données",
"delete_step2_exported": "Export téléchargé",
"delete_password_label": "Confirmez votre mot de passe",
"delete_phrase_label": "Tapez pour confirmer :",
"delete_wrong_password": "Le mot de passe est incorrect.",
"sessions_unknown_device": "Appareil inconnu"
},
"nav": {
"search_placeholder": "Rechercher...",
"no_results": "Aucun résultat trouvé.",
"notifications": "Notifications",
"no_notifications": "Aucune nouvelle notification.",
"mark_read": "Marquer comme lu",
"mark_all_read": "Tout marquer comme lu",
"more_coming_soon": "D'autres fonctionnalités arrivent bientôt",
"sign_out": "Déconnexion",
"profile": "Profil",
"settings": "Paramètres",
"dark_mode": "Mode sombre",
"light_mode": "Mode clair"
},
"search": {
"accounts": "Comptes",
"budgets": "Budgets",
"expenses": "Dépenses",
"transactions": "Transactions",
"deadlines": "Échéances"
},
"sidebar": {
"dashboard": "Tableau de bord",
"budgets": "Budgets",
"fixed_costs": "Charges fixes",
"expenses": "Dépenses",
"calendar": "Calendrier",
"accounts": "Comptes",
"revenue_accounts": "Comptes de revenus",
"transactions": "Transactions"
},
"dashboard": {
"title": "Tableau de bord",
"subtitle": "Aperçu financier",
"total_income": "Revenus totaux",
"fixed_costs": "Charges fixes",
"expenses": "Dépenses",
"balance": "Solde",
"per_month": "CHF / mois",
"chf_total": "CHF total",
"chf_remaining": "CHF restants",
"income_vs_expenses": "Revenus vs. Dépenses {{ year }}",
"fixed_costs_breakdown": "Répartition des charges fixes",
"savings_rate": "Taux d'épargne",
"of_income": "des revenus",
"goal": "Objectif d'épargne",
"goal_hint": "Recommandé : épargner au moins 20 % de ses revenus.",
"recent_expenses": "Dernières dépenses",
"no_expenses": "Aucune dépense enregistrée.",
"series_income": "Revenus",
"series_fixed_costs": "Charges fixes",
"series_expenses": "Dépenses variables",
"view_report": "Voir le rapport",
"greeting_morning": "Bonjour",
"greeting_afternoon": "Bon après-midi",
"greeting_evening": "Bonsoir",
"greeting_night": "Bonne nuit"
},
"accounts": {
"title": "Tous les comptes",
"add": "Ajouter un compte",
"col_type": "Type",
"col_balance": "Solde (CHF)",
"no_accounts": "Aucun compte pour l'instant.",
"create_title": "Créer un compte",
"edit_title": "Modifier le compte",
"label_balance": "Solde (CHF)",
"label_type": "Type de compte",
"type_asset": "Actif",
"type_revenue": "Revenus",
"placeholder_name": "ex. Compte épargne"
},
"budgets": {
"title": "Charges fixes & Budget",
"subtitle": "Total des dépenses : ",
"add": "Ajouter",
"new_entry": "Nouvelle entrée — {{ category }}",
"edit_entry": "Modifier l'entrée",
"label_amount": "Montant (CHF)",
"label_category": "Catégorie",
"label_account": "Compte",
"label_active": "Actif",
"label_suggestions": "Suggestions",
"no_entries": "Aucune entrée dans cette catégorie.",
"entries_count": "({{ count }} entrées)",
"placeholder_name": "ex. Loyer",
"categories": {
"fixed_expenses": "Charges fixes",
"mobile_internet": "Mobile & Internet",
"subscriptions": "Abonnements",
"leisure": "Loisirs",
"tax_reserves": "Réserves fiscales",
"insurance": "Assurances",
"loans": "Emprunts & Crédits"
}
},
"expenses": {
"title": "Dépenses",
"total": "Total :",
"add": "Ajouter une dépense",
"col_date": "Date",
"col_name": "Nom",
"col_category": "Catégorie",
"col_account": "Compte",
"col_amount": "Montant",
"no_expenses": "Aucune dépense enregistrée.",
"create_title": "Ajouter une dépense",
"edit_title": "Modifier la dépense",
"label_amount": "Montant (CHF)",
"label_date": "Date",
"label_category": "Catégorie",
"label_account": "Compte",
"label_due_date": "Date d'échéance",
"label_notes": "Notes",
"placeholder_name": "ex. Migros",
"categories": {
"groceries": "Alimentation",
"dining": "Restaurants",
"transport": "Transport",
"health": "Santé & Médecine",
"clothing": "Vêtements",
"electronics": "Électronique",
"household": "Ménage",
"entertainment": "Divertissement",
"travel": "Voyages",
"other": "Autre"
}
},
"transactions": {
"title": "Toutes les transactions",
"add": "Ajouter une transaction",
"col_date": "Date",
"col_description": "Description",
"col_from": "De",
"col_to": "Vers",
"col_amount": "Montant",
"no_transactions": "Aucune transaction pour l'instant.",
"create_title": "Créer une transaction",
"edit_title": "Modifier la transaction",
"label_description": "Description",
"label_amount": "Montant (CHF)",
"label_date": "Date",
"label_from": "Compte source",
"label_to": "Compte destination",
"select_account": "Sélectionner un compte...",
"placeholder_description": "ex. Loyer janvier"
},
"calendar": {
"year_view": "Vue annuelle",
"subscribe": "S'abonner",
"ical_title": "Votre URL de flux iCal",
"ical_desc": "Ajoutez cette URL à n'importe quelle application calendrier (Protonmail, Google, Apple, Outlook).",
"ical_copy": "Copier",
"ical_copied": "Copié !",
"filter_holidays": "Jours fériés",
"filter_school": "Vacances scolaires",
"filter_invoices": "Factures",
"filter_deadlines": "Échéances",
"add_deadline": "Ajouter une échéance",
"select_type": "Choisir un motif...",
"label_title": "Titre",
"label_date": "Date",
"label_type": "Type",
"label_notes": "Notes",
"deadline_types": {
"tax": "Impôt",
"insurance": "Assurance",
"invoice": "Facture",
"personal": "Personnel",
"other": "Autre"
},
"months": {
"1": "Janvier",
"2": "Février",
"3": "Mars",
"4": "Avril",
"5": "Mai",
"6": "Juin",
"7": "Juillet",
"8": "Août",
"9": "Septembre",
"10": "Octobre",
"11": "Novembre",
"12": "Décembre"
},
"weekdays": [
"Lun",
"Mar",
"Mer",
"Jeu",
"Ven",
"Sam",
"Dim"
],
"no_events": "Aucun événement ce jour.",
"more_events": "+{{ count }} autres",
"legend_national": "Fête nationale",
"legend_cantonal": "Fête cantonale",
"legend_school": "Vacances scolaires",
"legend_expense": "Date d'échéance",
"legend_personal": "Échéance personnelle"
},
"canton_names": {
"AG": "Argovie",
"AI": "Appenzell Rhodes-Intérieures",
"AR": "Appenzell Rhodes-Extérieures",
"BE": "Berne",
"BL": "Bâle-Campagne",
"BS": "Bâle-Ville",
"FR": "Fribourg",
"GE": "Genève",
"GL": "Glaris",
"GR": "Grisons",
"JU": "Jura",
"LU": "Lucerne",
"NE": "Neuchâtel",
"NW": "Nidwald",
"OW": "Obwald",
"SG": "Saint-Gall",
"SH": "Schaffhouse",
"SO": "Soleure",
"SZ": "Schwytz",
"TG": "Thurgovie",
"TI": "Tessin",
"UR": "Uri",
"VD": "Vaud",
"VS": "Valais",
"ZG": "Zoug",
"ZH": "Zurich"
},
"profile": {
"title": "Profil",
"subtitle": "Gérer vos informations personnelles et paramètres",
"personal_info": "Informations personnelles",
"profile_photo": "Photo de profil",
"photo_hint": "Cliquez sur l'avatar pour télécharger une photo",
"fallback_color": "Couleur de repli",
"first_name": "Prénom",
"last_name": "Nom de famille",
"email": "E-mail",
"canton": "Canton",
"language": "Langue",
"save_changes": "Enregistrer les modifications",
"save_success": "Profil enregistré avec succès.",
"change_password": "Changer le mot de passe",
"new_password": "Nouveau mot de passe",
"confirm_password": "Confirmer le mot de passe",
"update_password": "Mettre à jour le mot de passe",
"password_success": "Mot de passe mis à jour avec succès.",
"totp_title": "Authentification à deux facteurs",
"totp_subtitle": "Protégez votre compte avec une application d'authentification. Recommandé : Proton Pass, Aegis (Android) ou Raivo OTP (iOS).",
"totp_on": "Activé",
"totp_off": "Désactivé",
"totp_enable": "Activer le 2FA",
"totp_disable": "Désactiver le 2FA",
"totp_scan_hint": "Scannez le QR code avec votre application d'authentification, puis saisissez le code à 6 chiffres.",
"totp_disable_hint": "Saisissez le code actuel de votre application d'authentification pour désactiver le 2FA.",
"totp_code_label": "Code de confirmation",
"totp_confirm": "Confirmer & activer",
"totp_enabled_success": "2FA activé avec succès.",
"totp_disabled_success": "Le 2FA a été désactivé.",
"totp_invalid_code": "Code invalide. Veuillez réessayer.",
"backup_codes_title": "Enregistrez vos codes de secours",
"backup_codes_hint": "Ces codes ne sont affichés qu'une seule fois. Conservez-les en lieu sûr.",
"backup_copy": "Copier",
"backup_copied": "Copié !",
"backup_download_pdf": "Télécharger en PDF",
"backup_saved": "Je les ai enregistrés",
"danger_zone": "Zone dangereuse",
"danger_text": "Supprimez définitivement votre compte et toutes les données associées. Cette action est irréversible.",
"delete_account": "Supprimer le compte",
"delete_account_confirm": "Supprimer le compte ?",
"delete_account_text": "Toutes vos données seront définitivement supprimées. Cette action est irréversible.",
"languages": {
"de": "Deutsch",
"fr": "Français",
"it": "Italiano",
"en": "English"
},
"errors": {
"password_empty": "Le mot de passe ne peut pas être vide.",
"passwords_mismatch": "Les mots de passe ne correspondent pas.",
"password_too_short": "Le mot de passe doit comporter au moins 8 caractères.",
"password_failed": "Échec de la mise à jour du mot de passe."
}
}
}
+404
View File
@@ -0,0 +1,404 @@
{
"common": {
"save_changes": "Salva modifiche",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"create": "Crea",
"add": "Aggiungi",
"close": "Chiudi",
"save": "Salva",
"name": "Nome",
"optional": "opzionale",
"delete_confirm_title": "Eliminare la voce?",
"delete_confirm_text": "Questa azione non può essere annullata.",
"no_accounts_title": "Nessun conto disponibile",
"no_accounts_text": "Per creare una voce, devi prima configurare un conto.",
"go_to_accounts": "Vai ai conti"
},
"auth": {
"sign_in": "Accedi",
"sign_up": "Registrati",
"signing_in": "Accesso in corso...",
"creating_account": "Creazione account...",
"username": "Nome utente",
"email": "E-mail",
"password": "Password",
"confirm_password": "Conferma password",
"tagline_login": "Accedi al tuo account",
"tagline_register": "Crea il tuo account",
"no_account": "Non hai un account?",
"create_account": "Crea un account",
"has_account": "Hai già un account?",
"password_hint": "Almeno 8 caratteri.",
"username_hint": "Utilizzato come nome visualizzato nell'app.",
"totp_title": "Autenticazione a due fattori",
"totp_hint": "Inserisci il codice a 6 cifre dalla tua app di autenticazione.",
"totp_or_backup": "Oppure inserisci un codice di backup.",
"totp_no_device": "Non ho il mio dispositivo 2FA",
"totp_use_backup": "Usa il codice di recupero",
"totp_no_backup": "Non ho nemmeno un codice di recupero",
"backup_format_hint": "Formato: XXXXXXXX-XXXXXXXX",
"recovery_title": "Recupero account",
"recovery_intro": "Per mantenere il tuo account sicuro, vogliamo assicurarci che tu sia davvero tu. Un codice di conferma verrà inviato alla tua e-mail di recupero registrata.",
"recovery_send": "Invia codice di conferma",
"recovery_sent": "Un'e-mail con un codice di conferma è stata appena inviata a:",
"recovery_spam_hint": "Se non trovi il messaggio nella tua casella di posta, controlla la cartella spam.",
"recovery_confirm": "Conferma codice",
"recovery_verifying": "Verifica del link in corso…",
"recovery_success": "2FA disattivato. Reindirizzamento in corso…",
"recovery_redirecting": "Reindirizzamento al dashboard…",
"recovery_error": "Il link non è valido o è scaduto.",
"keep_signed_in": "Rimani connesso",
"keep_signed_in_hint": "Consigliato sui dispositivi affidabili.",
"back_to_login": "Torna al login",
"errors": {
"fields_required": "Tutti i campi sono obbligatori.",
"passwords_mismatch": "Le password non corrispondono.",
"password_too_short": "La password deve contenere almeno 8 caratteri.",
"invalid_credentials": "Nome utente o password non validi.",
"enter_credentials": "Inserisci nome utente e password.",
"registration_failed": "Registrazione fallita.",
"invalid_totp": "Codice non valido o scaduto.",
"captcha_failed": "Verifica di sicurezza fallita. Riprova.",
"reset_failed": "Link non valido o scaduto.",
"token_missing": "Nessun token trovato.",
"verify_failed": "Link di verifica non valido o scaduto."
},
"forgot_password": "Password dimenticata?",
"forgot_password_tagline": "Reimposta password",
"forgot_password_hint": "Inserisci il tuo indirizzo e-mail. Se esiste un account, ti invieremo un link di reimpostazione.",
"send_reset_link": "Invia link di reimpostazione",
"sending": "Invio in corso...",
"reset_link_sent": "Link inviato",
"reset_link_sent_hint": "Se esiste un account con questo indirizzo, riceverai un'e-mail. Controlla anche la cartella spam.",
"reset_password": "Reimposta password",
"reset_password_tagline": "Imposta nuova password",
"reset_password_hint": "Inserisci la tua nuova password.",
"new_password": "Nuova password",
"resetting": "Reimpostazione in corso...",
"reset_success": "Password modificata con successo.",
"verifying": "Verifica dell'indirizzo e-mail in corso...",
"email_verified": "Indirizzo e-mail verificato.",
"verify_email_error": "Verifica non riuscita"
},
"settings": {
"subtitle": "Sicurezza e impostazioni dell'account",
"recovery_email": "E-mail di recupero",
"recovery_email_hint": "Questa e-mail viene utilizzata per il recupero 2FA. Usa un indirizzo diverso dalla tua e-mail di accesso.",
"recovery_email_saved": "E-mail salvata.",
"sessions_title": "Sessioni attive",
"sessions_hint": "Tutti i dispositivi su cui sei attualmente connesso.",
"sessions_current": "Sessione corrente",
"sessions_revoke": "Disconnetti",
"sessions_revoke_all": "Disconnetti tutte le altre",
"sessions_loading": "Caricamento sessioni…",
"export_title": "Esporta dati",
"export_hint": "Scarica una cartella ZIP con tutti i tuoi dati in formato PDF.",
"export_btn": "Scarica esportazione",
"export_loading": "Preparazione esportazione…",
"notif_title": "Notifiche",
"notif_hint": "Scegli di cosa vuoi essere informato.",
"notif_deadlines": "Scadenze imminenti",
"notif_deadlines_hint": "Promemoria per appuntamenti e scadenze imminenti.",
"notif_budget_alerts": "Avvisi budget",
"notif_budget_alerts_hint": "Notifica quando un budget viene superato.",
"notif_monthly_summary": "Riepilogo mensile",
"notif_monthly_summary_hint": "Panoramica di entrate e uscite a fine mese.",
"notif_saved": "Impostazioni salvate.",
"delete_step1_title": "Esporta prima i tuoi dati",
"delete_step1_hint": "Scarica i tuoi dati prima di eliminare l'account. Questa azione non può essere annullata.",
"delete_step1_btn": "Esporta dati",
"delete_step2_exported": "Esportazione scaricata",
"delete_password_label": "Conferma la tua password",
"delete_phrase_label": "Digita per confermare:",
"delete_wrong_password": "La password non è corretta.",
"sessions_unknown_device": "Dispositivo sconosciuto"
},
"nav": {
"search_placeholder": "Cerca...",
"no_results": "Nessun risultato trovato.",
"notifications": "Notifiche",
"no_notifications": "Nessuna nuova notifica.",
"mark_read": "Segna come letto",
"mark_all_read": "Segna tutto come letto",
"more_coming_soon": "Altre funzionalità in arrivo",
"sign_out": "Esci",
"profile": "Profilo",
"settings": "Impostazioni",
"dark_mode": "Modalità scura",
"light_mode": "Modalità chiara"
},
"search": {
"accounts": "Conti",
"budgets": "Budget",
"expenses": "Spese",
"transactions": "Transazioni",
"deadlines": "Scadenze"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budget",
"fixed_costs": "Costi fissi",
"expenses": "Spese",
"calendar": "Calendario",
"accounts": "Conti",
"revenue_accounts": "Conti entrate",
"transactions": "Transazioni"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Panoramica finanziaria",
"total_income": "Entrate totali",
"fixed_costs": "Costi fissi",
"expenses": "Spese",
"balance": "Saldo",
"per_month": "CHF / mese",
"chf_total": "CHF totale",
"chf_remaining": "CHF rimanenti",
"income_vs_expenses": "Entrate vs. Spese {{ year }}",
"fixed_costs_breakdown": "Ripartizione costi fissi",
"savings_rate": "Tasso di risparmio",
"of_income": "delle entrate",
"goal": "Obiettivo di risparmio",
"goal_hint": "Consigliato: risparmiare almeno il 20% delle entrate.",
"recent_expenses": "Spese recenti",
"no_expenses": "Nessuna spesa registrata.",
"series_income": "Entrate",
"series_fixed_costs": "Costi fissi",
"series_expenses": "Spese variabili",
"view_report": "Visualizza rapporto",
"greeting_morning": "Buongiorno",
"greeting_afternoon": "Buon pomeriggio",
"greeting_evening": "Buona sera",
"greeting_night": "Buona notte"
},
"accounts": {
"title": "Tutti i conti",
"add": "Aggiungi conto",
"col_type": "Tipo",
"col_balance": "Saldo (CHF)",
"no_accounts": "Nessun conto ancora.",
"create_title": "Crea conto",
"edit_title": "Modifica conto",
"label_balance": "Saldo (CHF)",
"label_type": "Tipo di conto",
"type_asset": "Attivo",
"type_revenue": "Entrate",
"placeholder_name": "es. Conto risparmio"
},
"budgets": {
"title": "Costi fissi & Budget",
"subtitle": "Spese totali: ",
"add": "Aggiungi",
"new_entry": "Nuova voce — {{ category }}",
"edit_entry": "Modifica voce",
"label_amount": "Importo (CHF)",
"label_category": "Categoria",
"label_account": "Conto",
"label_active": "Attivo",
"label_suggestions": "Suggerimenti",
"no_entries": "Nessuna voce in questa categoria.",
"entries_count": "({{ count }} voci)",
"placeholder_name": "es. Affitto",
"categories": {
"fixed_expenses": "Spese fisse",
"mobile_internet": "Mobile & Internet",
"subscriptions": "Abbonamenti",
"leisure": "Tempo libero",
"tax_reserves": "Riserve fiscali",
"insurance": "Assicurazioni",
"loans": "Prestiti & Crediti"
}
},
"expenses": {
"title": "Spese",
"total": "Totale:",
"add": "Aggiungi spesa",
"col_date": "Data",
"col_name": "Nome",
"col_category": "Categoria",
"col_account": "Conto",
"col_amount": "Importo",
"no_expenses": "Nessuna spesa registrata.",
"create_title": "Aggiungi spesa",
"edit_title": "Modifica spesa",
"label_amount": "Importo (CHF)",
"label_date": "Data",
"label_category": "Categoria",
"label_account": "Conto",
"label_due_date": "Data di scadenza",
"label_notes": "Note",
"placeholder_name": "es. Migros",
"categories": {
"groceries": "Spesa alimentare",
"dining": "Ristoranti",
"transport": "Trasporti",
"health": "Salute & Medicina",
"clothing": "Abbigliamento",
"electronics": "Elettronica",
"household": "Casa",
"entertainment": "Intrattenimento",
"travel": "Viaggi",
"other": "Altro"
}
},
"transactions": {
"title": "Tutte le transazioni",
"add": "Aggiungi transazione",
"col_date": "Data",
"col_description": "Descrizione",
"col_from": "Da",
"col_to": "A",
"col_amount": "Importo",
"no_transactions": "Nessuna transazione ancora.",
"create_title": "Crea transazione",
"edit_title": "Modifica transazione",
"label_description": "Descrizione",
"label_amount": "Importo (CHF)",
"label_date": "Data",
"label_from": "Conto di origine",
"label_to": "Conto di destinazione",
"select_account": "Seleziona conto...",
"placeholder_description": "es. Affitto gennaio"
},
"calendar": {
"year_view": "Vista annuale",
"subscribe": "Abbonati",
"ical_title": "Il tuo URL del feed iCal",
"ical_desc": "Aggiungi questo URL a qualsiasi app di calendario (Protonmail, Google, Apple, Outlook).",
"ical_copy": "Copia",
"ical_copied": "Copiato!",
"filter_holidays": "Festività",
"filter_school": "Vacanze scolastiche",
"filter_invoices": "Fatture",
"filter_deadlines": "Scadenze",
"add_deadline": "Aggiungi scadenza",
"select_type": "Seleziona motivo...",
"label_title": "Titolo",
"label_date": "Data",
"label_type": "Tipo",
"label_notes": "Note",
"deadline_types": {
"tax": "Tasse",
"insurance": "Assicurazione",
"invoice": "Fattura",
"personal": "Personale",
"other": "Altro"
},
"months": {
"1": "Gennaio",
"2": "Febbraio",
"3": "Marzo",
"4": "Aprile",
"5": "Maggio",
"6": "Giugno",
"7": "Luglio",
"8": "Agosto",
"9": "Settembre",
"10": "Ottobre",
"11": "Novembre",
"12": "Dicembre"
},
"weekdays": [
"Lun",
"Mar",
"Mer",
"Gio",
"Ven",
"Sab",
"Dom"
],
"no_events": "Nessun evento in questo giorno.",
"more_events": "+{{ count }} altri",
"legend_national": "Festa nazionale",
"legend_cantonal": "Festa cantonale",
"legend_school": "Vacanze scolastiche",
"legend_expense": "Data di scadenza",
"legend_personal": "Scadenza personale"
},
"canton_names": {
"AG": "Argovia",
"AI": "Appenzello Interno",
"AR": "Appenzello Esterno",
"BE": "Berna",
"BL": "Basilea Campagna",
"BS": "Basilea Città",
"FR": "Friburgo",
"GE": "Ginevra",
"GL": "Glarona",
"GR": "Grigioni",
"JU": "Giura",
"LU": "Lucerna",
"NE": "Neuchâtel",
"NW": "Nidvaldo",
"OW": "Obvaldo",
"SG": "San Gallo",
"SH": "Sciaffusa",
"SO": "Soletta",
"SZ": "Svitto",
"TG": "Turgovia",
"TI": "Ticino",
"UR": "Uri",
"VD": "Vaud",
"VS": "Vallese",
"ZG": "Zugo",
"ZH": "Zurigo"
},
"profile": {
"title": "Profilo",
"subtitle": "Gestisci le tue informazioni personali e impostazioni",
"personal_info": "Informazioni personali",
"profile_photo": "Foto profilo",
"photo_hint": "Clicca sull'avatar per caricare una foto",
"fallback_color": "Colore di riserva",
"first_name": "Nome",
"last_name": "Cognome",
"email": "E-mail",
"canton": "Cantone",
"language": "Lingua",
"save_changes": "Salva modifiche",
"save_success": "Profilo salvato con successo.",
"change_password": "Cambia password",
"new_password": "Nuova password",
"confirm_password": "Conferma password",
"update_password": "Aggiorna password",
"password_success": "Password aggiornata con successo.",
"totp_title": "Autenticazione a due fattori",
"totp_subtitle": "Proteggi il tuo account con un'app di autenticazione. Consigliato: Proton Pass, Aegis (Android) o Raivo OTP (iOS).",
"totp_on": "Attivo",
"totp_off": "Disattivo",
"totp_enable": "Attiva 2FA",
"totp_disable": "Disattiva 2FA",
"totp_scan_hint": "Scansiona il codice QR con la tua app di autenticazione, poi inserisci il codice a 6 cifre.",
"totp_disable_hint": "Inserisci il codice attuale dalla tua app di autenticazione per disattivare il 2FA.",
"totp_code_label": "Codice di conferma",
"totp_confirm": "Conferma e attiva",
"totp_enabled_success": "2FA attivato con successo.",
"totp_disabled_success": "Il 2FA è stato disattivato.",
"totp_invalid_code": "Codice non valido. Riprova.",
"backup_codes_title": "Salva i tuoi codici di backup",
"backup_codes_hint": "Questi codici vengono mostrati una sola volta. Conservali in un posto sicuro.",
"backup_copy": "Copia",
"backup_copied": "Copiato!",
"backup_download_pdf": "Scarica come PDF",
"backup_saved": "Li ho salvati",
"danger_zone": "Zona pericolosa",
"danger_text": "Elimina definitivamente il tuo account e tutti i dati associati. Questa azione non può essere annullata.",
"delete_account": "Elimina account",
"delete_account_confirm": "Eliminare l'account?",
"delete_account_text": "Tutti i tuoi dati saranno eliminati definitivamente. Questa azione non può essere annullata.",
"languages": {
"de": "Deutsch",
"fr": "Français",
"it": "Italiano",
"en": "English"
},
"errors": {
"password_empty": "La password non può essere vuota.",
"passwords_mismatch": "Le password non corrispondono.",
"password_too_short": "La password deve contenere almeno 8 caratteri.",
"password_failed": "Aggiornamento password fallito."
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Armarium</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/svg+xml" href="assets/Icon.svg">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
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));
+32
View File
@@ -0,0 +1,32 @@
@import '@fontsource/roboto/300.css';
@import '@fontsource/roboto/400.css';
@import '@fontsource/roboto/500.css';
@import '@fontsource/roboto/700.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
font-family: 'Roboto', 'ui-sans-serif', 'system-ui', '-apple-system', 'Helvetica Neue', 'Arial', 'sans-serif';
font-size: 0.875rem; /* 14px Desktop */
line-height: 1.375rem;
}
@media (max-width: 640px) {
body {
font-size: 0.9375rem; /* 15px Mobile */
line-height: 1.5rem;
}
}
h1 { font-size: 1.75rem; line-height: 2.25rem; font-weight: 700; } /* 28px */
h2 { font-size: 1.375rem; line-height: 2rem; font-weight: 600; } /* 22px */
h3 { font-size: 1.125rem; line-height: 1.75rem; font-weight: 600; } /* 18px */
@media (max-width: 640px) {
h1 { font-size: 1.5rem; line-height: 2rem; } /* 24px Mobile */
h2 { font-size: 1.25rem; line-height: 1.75rem; } /* 20px Mobile */
h3 { font-size: 1.0625rem; line-height: 1.5rem; } /* 17px Mobile */
}
}
+34
View File
@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./src/**/*.{html,ts}",
],
safelist: [
'w-16',
'w-64',
'ml-16',
'ml-64',
'-translate-x-full',
],
theme: {
extend: {
fontFamily: {
sans: ['Roboto', 'ui-sans-serif', 'system-ui', '-apple-system', 'Helvetica Neue', 'Arial', 'sans-serif'],
},
fontSize: {
// Tooltips / Helper Text
'2xs': ['0.6875rem', { lineHeight: '1rem' }], // 11px
'xs': ['0.75rem', { lineHeight: '1rem' }], // 12px Labels/Captions
// Body / Buttons / Navigation
'sm': ['0.875rem', { lineHeight: '1.375rem' }], // 14px Desktop standard
'base':['0.9375rem', { lineHeight: '1.5rem' }], // 15px Mobile Body
// Headings
'h3': ['1.125rem', { lineHeight: '1.75rem' }], // 18px
'h2': ['1.375rem', { lineHeight: '2rem' }], // 22px
'h1': ['1.75rem', { lineHeight: '2.25rem' }], // 28px Desktop
},
},
},
plugins: [],
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+33
View File
@@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}