Basic API consumption support and refactored UI to use material (#1)

Co-authored-by: Joe Arndt <jmarndt@users.noreply.github.com>
Reviewed-on: #1
This commit is contained in:
Joe 2026-02-23 21:16:31 +00:00
parent bd18fecdff
commit f2226b2bfd
34 changed files with 726 additions and 55 deletions

View file

@ -0,0 +1,47 @@
<div class="expense-form-container">
<mat-form-field appearance="outline">
<mat-label>Date</mat-label>
<input matInput [matDatepicker]="expenseDatePicker" [formField]="form.date">
<mat-datepicker-toggle matIconSuffix [for]="expenseDatePicker"></mat-datepicker-toggle>
<mat-datepicker #expenseDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cents</mat-label>
<input type="number" matInput [formField]="form.cents">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Category</mat-label>
<input type="text" matInput [matAutocomplete]="categoryAuto" [formField]="form.category">
<mat-autocomplete [displayWith]="autocompleteDisplay" autoActiveFirstOption #categoryAuto="matAutocomplete">
@for (category of categories(); track category.id) {
<mat-option [value]="category">{{ category.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Merchant</mat-label>
<input type="text" matInput [matAutocomplete]="merchantAuto" [formField]="form.merchant">
<mat-autocomplete [displayWith]="autocompleteDisplay" autoActiveFirstOption #merchantAuto="matAutocomplete">
@for (merchant of merchants(); track merchant.id) {
<mat-option [value]="merchant">{{ merchant.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Note</mat-label>
<textarea rows="1" matInput [formField]="form.note"></textarea>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tags</mat-label>
<mat-select disableRipple multiple [formField]="form.tags">
@for (tag of tags(); track tag.id) {
<mat-option [value]="tag">{{ tag.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>

View file

@ -0,0 +1,20 @@
.expense-form-container {
display: grid;
mat-form-field {
width: 100%;
}
}
@media (min-width: 550px) {
.expense-form-container {
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}
@media (min-width: 800px) {
.expense-form-container {
grid-template-columns: 1fr 1fr 1fr;
}
}

View file

@ -0,0 +1,124 @@
import { Component, computed, effect, input, OnInit, output, signal } from '@angular/core';
import { min, required, disabled, form, FormField, Schema, schema } from '@angular/forms/signals';
import { Expense } from '../../../services/expense.service';
import { Category, CategoryService } from '../../../services/category.service';
import { Merchant, MerchantService } from '../../../services/merchant.service';
import { Tag, TagService } from '../../../services/tag.service';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { provideNativeDateAdapter } from '@angular/material/core';
export interface ExpenseForm {
date: Date;
cents: number;
category: Category;
merchant: Merchant;
note: string;
tags: Tag[];
}
@Component({
selector: 'app-expense-form',
imports: [MatAutocompleteModule, MatDatepickerModule, MatInputModule, MatSelectModule, FormField],
providers: [provideNativeDateAdapter()],
templateUrl: './expense-form.component.html',
styleUrl: './expense-form.component.scss'
})
export class ExpenseFormComponent implements OnInit {
public expense = input<Expense>();
public disabled = input<boolean>(false);
public valid = output<boolean>();
public dirty = output<boolean>()
public value = output<ExpenseForm>();
private lastDate = signal<Date | undefined>(undefined);
private formValid = computed(() => this.form().valid() && this.form().dirty());
private formDirty = computed(() => this.form().dirty() || this.form().touched());
private formData = signal(this.buildForm(this.expense()));
private formModel = signal<ExpenseForm>(this.formData());
protected categories = computed(() => this.categoryService.categories());
protected merchants = computed(() => this.merchantService.merchants());
protected tags = computed(() => this.tagService.tags());
protected form = form(this.formModel, this.buildFormOptions());
constructor(private readonly categoryService: CategoryService,
private readonly merchantService: MerchantService,
private readonly tagService: TagService) {
effect(() => {
const valid = this.formValid();
const dirty = this.formDirty();
const value = this.form().value();
this.valid.emit(valid);
this.dirty.emit(dirty);
this.value.emit(value);
this.lastDate.set(value.date);
});
}
public ngOnInit(): void {
this.initialize();
}
public reset(clearDate = false): void {
if (clearDate) {
this.lastDate.set(undefined);
}
this.initialize();
}
public refresh(expense: Expense): void {
this.initialize();
this.formData.set(this.buildForm(expense));
this.formModel.set(this.formData());
this.form().reset(this.formModel());
}
public autocompleteDisplay(value: Merchant | Category) {
return value?.name ?? null;
}
private initialize(): void {
this.formData.set(this.buildForm(this.expense()));
this.formModel.set(this.formData());
this.form().reset(this.formModel());
}
private buildForm(expense?: Expense): ExpenseForm {
let formData = { date: this.lastDate() ?? '', cents: NaN, category: {}, merchant: {}, note: '', tags: [] } as unknown as ExpenseForm
if (expense) {
const date = expense.date.toString().split('-');
const day = Number(date[2]);
const month = Number(date[1]) - 1;
const year = Number(date[0]);
formData = {
date: new Date(year, month, day),
cents: expense.cents,
category: expense.category,
merchant: expense.merchant ?? {},
note: expense.note ?? '',
tags: expense.tags ?? []
} as ExpenseForm;
}
return formData;
}
private buildFormOptions(): Schema<ExpenseForm> {
return schema<ExpenseForm>((schema) => {
required(schema.date);
required(schema.cents);
required(schema.category);
min(schema.cents, 1);
disabled(schema.date, () => this.disabled());
disabled(schema.cents, () => this.disabled());
disabled(schema.category, () => this.disabled());
disabled(schema.merchant, () => this.disabled());
disabled(schema.note, () => this.disabled());
disabled(schema.tags, () => this.disabled());
});
}
}

View file

@ -0,0 +1,30 @@
<div class="expense-container">
<mat-card appearance="outlined">
<mat-card-header>
<div class="expense-header">
@if (!expense()) {
<mat-card-title>Add new Expense</mat-card-title>
<button matIconButton [disabled]="!formDirty() || savingExpense()" (click)="resetAddClick()"><mat-icon>replay</mat-icon></button>
<button matIconButton [disabled]="!formValid() || savingExpense()" (click)="addClick()"><mat-icon>add</mat-icon></button>
} @else {
@if (!editingExpense()) {
<mat-card-title>{{ expense()?.date?.toString() | date }}: {{ expense()?.cents! / 100 | currency }}</mat-card-title>
<button matIconButton (click)="editClick()"><mat-icon>edit</mat-icon></button>
} @else {
<mat-card-title>Update Expense</mat-card-title>
<button matIconButton [disabled]="savingExpense()" (click)="cancelUpdateClick()"><mat-icon>undo</mat-icon></button>
<button matIconButton [disabled]="!formValid() || savingExpense()" (click)="updateClick()"><mat-icon>save</mat-icon></button>
}
}
</div>
</mat-card-header>
<mat-card-content>
<app-expense-form [expense]="expense()"
[disabled]="savingExpense() || !!expense() && !editingExpense()"
(valid)="formValid.set($event)"
(dirty)="formDirty.set($event)"
(value)="formData.set($event)" />
</mat-card-content>
</mat-card>
</div>

View file

@ -0,0 +1,22 @@
.expense-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
mat-card {
max-width: 800px;
width: 100%;
}
}
mat-card-header {
padding-bottom: 1rem;
.expense-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
}

View file

@ -0,0 +1,109 @@
import { Component, model, signal, viewChild } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { CreateExpense, Expense, ExpenseService, UpdateExpense } from '../../services/expense.service';
import { MatIcon } from '@angular/material/icon';
import { ExpenseForm, ExpenseFormComponent } from './expense-form/expense-form.component';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { Temporal } from '@js-temporal/polyfill';
import { SnackBarService } from '../../services/snack-bar';
@Component({
selector: 'app-expense',
imports: [MatCardModule, MatButtonModule, MatIcon, ExpenseFormComponent, DatePipe, CurrencyPipe],
providers: [],
templateUrl: './expense.component.html',
styleUrl: './expense.component.scss'
})
export class ExpenseComponent {
private form = viewChild(ExpenseFormComponent);
public editingExpense = signal(false);
public savingExpense = signal(false);
public expense = model<Expense>();
public formValid = model(false);
public formDirty = model(false);
public formData = model<ExpenseForm>();
public constructor(private readonly expenseService: ExpenseService,
private readonly snackBar: SnackBarService) { }
public editClick(): void {
this.editingExpense.set(true);
}
public async addClick(): Promise<void> {
const form = this.formData()!;
const postExpense: CreateExpense = {
date: this.dateToPlainDate(form.date),
cents: form.cents,
categoryId: form.category.id,
note: !!form.note ? form.note : undefined,
merchantId: !!form.merchant ? form.merchant.id : undefined,
tagIds: form.tags.map(tag => tag.id)
};
this.savingExpense.set(true);
const snackId = this.snackBar.staticBar('Tracking new expense...');
try {
await this.expenseService.postExpense(postExpense);
this.resetForm();
this.snackBar.dismiss(snackId);
this.snackBar.autoBar('Expense tracked!');
}
catch (error) {
this.snackBar.dismiss(snackId);
this.snackBar.dismissableBar('Error: '); // TODO
}
finally {
this.savingExpense.set(false);
}
}
public resetAddClick(): void {
this.resetForm(true);
}
public async updateClick(): Promise<void> {
const form = this.formData()!;
const putExpense: UpdateExpense = {
id: this.expense()!.id,
date: this.dateToPlainDate(form.date),
cents: form.cents,
categoryId: form.category.id,
note: !!form.note ? form.note : undefined,
merchantId: !!form.merchant ? form.merchant.id : undefined,
tagIds: form.tags.map(tag => tag.id)
};
this.savingExpense.set(true);
const snackId = this.snackBar.staticBar('Updating expense...');
try {
const expense = await this.expenseService.updateExpense(putExpense);
this.expense.set(expense);
this.form()?.refresh(expense);
this.editingExpense.set(false);
this.snackBar.dismiss(snackId);
this.snackBar.autoBar('Expense updated!');
}
catch (error) {
this.snackBar.dismiss(snackId);
this.snackBar.dismissableBar('Error: '); // TODO
}
finally {
this.savingExpense.set(false);
}
}
public cancelUpdateClick(): void {
this.resetForm();
}
private resetForm(clearDate = false): void {
this.form()?.reset(clearDate);
this.editingExpense.set(false);
}
private dateToPlainDate(date: Date): Temporal.PlainDate {
return new Temporal.PlainDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
}