diff --git a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html
new file mode 100644
index 0000000..2067eaf
--- /dev/null
+++ b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ g.isActive ? 'Yes' : 'No' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a group on the left to view its subcategories.
+
+
+
+
+
+ {{ s.isActive ? 'Yes' : 'No' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss
new file mode 100644
index 0000000..0bc81e8
--- /dev/null
+++ b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss
@@ -0,0 +1,30 @@
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+
+ h3 {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ }
+}
+
+.selected-label {
+ font-weight: 400;
+ color: #555;
+}
+
+.hint-text {
+ padding: 1.5rem 0;
+ color: #888;
+ font-style: italic;
+}
diff --git a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts
new file mode 100644
index 0000000..d309cde
--- /dev/null
+++ b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts
@@ -0,0 +1,93 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { GridModule } from '@progress/kendo-angular-grid';
+import { ButtonsModule } from '@progress/kendo-angular-buttons';
+import { DialogsModule } from '@progress/kendo-angular-dialog';
+import { InputsModule } from '@progress/kendo-angular-inputs';
+import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
+import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
+
+@Component({
+ selector: 'app-expense-categories-page',
+ standalone: true,
+ imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule],
+ templateUrl: './expense-categories-page.component.html',
+ styleUrls: ['./expense-categories-page.component.scss'],
+})
+export class ExpenseCategoriesPageComponent implements OnInit {
+ groups: ExpenseCategoryGroupDto[] = [];
+ selectedGroup: ExpenseCategoryGroupDto | null = null;
+ loading = false;
+
+ groupDialogOpen = false;
+ editingGroupId: number | null = null;
+ groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
+
+ subDialogOpen = false;
+ editingSubId: number | null = null;
+ subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
+
+ constructor(private api: ExpenseCategoryApiService) {}
+
+ ngOnInit(): void { this.load(); }
+
+ load(): void {
+ this.loading = true;
+ this.api.getAll(true).subscribe({
+ next: g => {
+ this.groups = g;
+ if (this.selectedGroup) this.selectedGroup = g.find(x => x.id === this.selectedGroup!.id) ?? null;
+ this.loading = false;
+ },
+ error: () => { this.loading = false; },
+ });
+ }
+
+ selectGroup(g: ExpenseCategoryGroupDto): void { this.selectedGroup = g; }
+ get subCategories(): ExpenseSubCategoryDto[] { return this.selectedGroup?.subCategories ?? []; }
+
+ openNewGroup(): void {
+ this.editingGroupId = null;
+ this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true };
+ this.groupDialogOpen = true;
+ }
+ openEditGroup(g: ExpenseCategoryGroupDto): void {
+ this.editingGroupId = g.id;
+ this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive };
+ this.groupDialogOpen = true;
+ }
+ saveGroup(): void {
+ const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder };
+ const done = () => { this.groupDialogOpen = false; this.load(); };
+ if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
+ else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
+ }
+ deactivateGroup(g: ExpenseCategoryGroupDto): void {
+ if (!confirm(`Deactivate "${g.name_en}"?`)) return;
+ this.api.deactivateGroup(g.id).subscribe(() => this.load());
+ }
+
+ openNewSub(): void {
+ if (!this.selectedGroup) return;
+ this.editingSubId = null;
+ this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true };
+ this.subDialogOpen = true;
+ }
+ openEditSub(s: ExpenseSubCategoryDto): void {
+ this.editingSubId = s.id;
+ this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive };
+ this.subDialogOpen = true;
+ }
+ saveSub(): void {
+ if (!this.selectedGroup) return;
+ const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder };
+ const done = () => { this.subDialogOpen = false; this.load(); };
+ if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
+ else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
+ }
+ deactivateSub(s: ExpenseSubCategoryDto): void {
+ if (!confirm(`Deactivate "${s.name_en}"?`)) return;
+ this.api.deactivateSub(s.id).subscribe(() => this.load());
+ }
+}