feat(expense): add expense categories management page
This commit is contained in:
+112
@@ -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>
|
||||
+30
@@ -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;
|
||||
}
|
||||
+93
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user