feat(expense): add expense categories management page

This commit is contained in:
Chris Chen
2026-05-29 18:43:19 -07:00
parent 04b05617b8
commit 3188064335
3 changed files with 235 additions and 0 deletions
@@ -0,0 +1,112 @@
<div class="page">
<header class="page-header">
<h2>Expense Categories / 費用類別</h2>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Left: Category Groups -->
<div>
<div class="panel-header">
<h3>Groups / 組別</h3>
<button kendoButton themeColor="primary" (click)="openNewGroup()">+ New Group</button>
</div>
<kendo-grid [data]="groups" [loading]="loading">
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="70">
<ng-template kendoGridCellTemplate let-g>{{ g.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="180">
<ng-template kendoGridCellTemplate let-g>
<button kendoButton fillMode="flat" (click)="selectGroup(g)" [themeColor]="selectedGroup?.id === g.id ? 'primary' : 'base'">Select</button>
<button kendoButton fillMode="flat" (click)="openEditGroup(g)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="g.isActive" (click)="deactivateGroup(g)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
</div>
<!-- Right: Subcategories of selected group -->
<div>
<div class="panel-header">
<h3>Subcategories / 子類別<span *ngIf="selectedGroup" class="selected-label"> — {{ selectedGroup.name_en }}</span></h3>
<button kendoButton themeColor="primary" [disabled]="!selectedGroup" (click)="openNewSub()">+ New Subcategory</button>
</div>
<div *ngIf="!selectedGroup" class="hint-text">Select a group on the left to view its subcategories.</div>
<kendo-grid *ngIf="selectedGroup" [data]="subCategories" [loading]="loading">
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="70">
<ng-template kendoGridCellTemplate let-s>{{ s.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="150">
<ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" (click)="openEditSub(s)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="s.isActive" (click)="deactivateSub(s)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
</div>
</div>
<!-- Group Dialog -->
<kendo-dialog *ngIf="groupDialogOpen"
[title]="editingGroupId != null ? 'Edit Group' : 'New Group'"
(close)="groupDialogOpen = false"
[width]="480">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Name (EN) *
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="groupForm.name_zh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="groupDialogOpen = false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!groupForm.name_en" (click)="saveGroup()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
<!-- Subcategory Dialog -->
<kendo-dialog *ngIf="subDialogOpen"
[title]="editingSubId != null ? 'Edit Subcategory' : 'New Subcategory'"
(close)="subDialogOpen = false"
[width]="480">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Name (EN) *
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="subForm.name_zh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="subDialogOpen = false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!subForm.name_en" (click)="saveSub()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -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;
}
@@ -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());
}
}