break expense component into smaller pieces

This commit is contained in:
Joe Arndt 2026-02-19 14:19:25 -06:00
parent e127b8ec45
commit 2ca674c3bb
18 changed files with 197 additions and 389 deletions

View file

@ -1,4 +1,5 @@
<div class="expense-list-container">
<h2>Expenses</h2>
@for (expense of expenses(); track expense.id) {
<app-expense [expense]="expense" />
}

View file

@ -0,0 +1,4 @@
.expense-list-container {
display: grid;
gap: 1rem;
}

View file

@ -1 +0,0 @@
<p>expense-add works!</p>

View file

@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-expense-add',
imports: [],
templateUrl: './expense-add.component.html',
styleUrl: './expense-add.component.scss',
})
export class ExpenseAddComponent {
}

View file

@ -1,24 +1,19 @@
<div class="expense-form-container">
<mat-form-field appearance="outline">
<mat-label>Date</mat-label>
<!-- @if (state() === 'view') {-->
<!-- &lt;!&ndash; <input matInput disabled="true" [matDatepicker]="expenseDatePicker" [placeholder]="expense()?.year! + '-' + expense()?.month! + '-' + expense()?.day!">&ndash;&gt;-->
<!-- <input matInput disabled="true" [matDatepicker]="expenseDatePicker" [placeholder]="'2026-12-03'" [value]="expenseForm.date().value()">-->
<!-- } @else {-->
<!-- <input matInput [matDatepicker]="expenseDatePicker" [formField]="expenseForm.date">-->
<!-- }-->
<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]="expenseForm.cents">-->
<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]="expenseForm.category" [value]="expenseForm.category()">-->
<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>
@ -28,7 +23,7 @@
<mat-form-field appearance="outline">
<mat-label>Merchant</mat-label>
<!-- <input type="text" matInput [matAutocomplete]="merchantAuto" [formField]="expenseForm.merchant">-->
<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>
@ -38,15 +33,15 @@
<mat-form-field appearance="outline">
<mat-label>Note</mat-label>
<!-- <textarea rows="1" matInput [formField]="expenseForm.note"></textarea>-->
<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]="expenseForm.tags">-->
<!-- @for (tag of tags(); track tag.id) {-->
<!-- <mat-option [value]="tag">{{ tag.name }}</mat-option>-->
<!-- }-->
<!-- </mat-select>-->
<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

@ -1,87 +1,96 @@
import {Component, computed, input, output, signal} from '@angular/core';
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 } from '../../../services/category.service';
import { Merchant } from '../../../services/merchant.service';
import { Tag } from '../../../services/tag.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 {form, min, required} from '@angular/forms/signals';
import { provideNativeDateAdapter } from '@angular/material/core';
interface ExpenseForm {
date: Date | string;
date: Date;
cents: number;
category: Category | string;
merchant: Merchant | string;
category: Category;
merchant: Merchant;
note: string;
tags: Tag[];
}
@Component({
selector: 'app-expense-form',
imports: [
MatAutocompleteModule,
MatDatepickerModule,
MatInputModule,
MatSelectModule
],
imports: [MatAutocompleteModule, MatDatepickerModule, MatInputModule, MatSelectModule, FormField],
providers: [provideNativeDateAdapter()],
templateUrl: './expense-form.component.html',
styleUrl: './expense-form.component.scss',
})
export class ExpenseFormComponent {
public disabled = input<boolean>(); // TODO: should this even exist?
public expense = input<Expense | undefined>(undefined);
public categories = input<Category[]>([]);
public merchants = input<Merchant[]>([]);
public tags = input<Tag[]>([]);
export class ExpenseFormComponent implements OnInit {
public expense = input<Expense>();
public disabled = input<boolean>(false);
public valid = output<boolean>();
public value = output(); // TODO: form value or expense value?
// protected enableSaveButton = computed(() => {
// const dateValid = this.expenseForm.date().valid();
// const centsValid = this.expenseForm.cents().valid();
// const categoryValid = this.expenseForm.category().valid();
// const merchantValid = this.expenseForm.merchant().valid();
// const noteValid = this.expenseForm.note().valid();
//
// return dateValid && centsValid && categoryValid && merchantValid && noteValid && !this.saving();
// });
public dirty = output<boolean>()
public value = output();
private formValid = computed(() => this.form().valid() && this.form().dirty());
private formDirty = computed(() => this.form().dirty() && this.form().touched());
private lastSelectedDate = signal<Date | undefined>(undefined);
private formData = computed(() => this.buildForm(this.expense()));
private formModel = signal<ExpenseForm>(this.formData());
private expenseDate = computed(() => {
const lastDate = this.lastSelectedDate();
const expense = this.expense();
if (expense) {
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(() => {
this.valid.emit(this.formValid());
this.dirty.emit(this.formDirty());
});
}
return this.expense()
? new Date()
: this.lastSelectedDate ?? '';
});
private defaultForm: ExpenseForm = {
date: new Date(),
cents: this.expense()?.cents ?? NaN,
category: this.expense()?.category ?? '',
merchant: this.expense()?.merchant ?? '',
note: this.expense()?.note ?? '',
tags: this.expense()?.tags ?? []
};
private expenseModel = signal<ExpenseForm>(this.defaultForm);
public expenseForm = form(this.expenseModel, (schema) => {
required(schema.date);
required(schema.cents);
min(schema.cents, 1);
required(schema.category);
});
public ngOnInit(): void {
console.log({ expense: this.expense() });
this.formModel.set(this.formData());
this.form().reset();
}
public autocompleteDisplay(value: Merchant | Category) {
return value.name ?? null;
return value?.name ?? null;
}
private buildForm(expense?: Expense): ExpenseForm {
let formData = { date: this.lastSelectedDate() ?? '', cents: '', category: '', merchant: '', note: '', tags: ''} as unknown as ExpenseForm
if (expense) {
formData = {
date: new Date(expense.date.toString()),
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

@ -1 +0,0 @@
<p>expense-view works!</p>

View file

@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-expense-view',
imports: [],
templateUrl: './expense-view.component.html',
styleUrl: './expense-view.component.scss',
})
export class ExpenseViewComponent {
}

View file

@ -1,135 +1,29 @@
<div class="expense-container">
<mat-card appearance="outlined">
<mat-card-header>
@if (state() === 'add' || state() === 'edit') {
<div class="expense-header">
<mat-card-title>
@if (state() === 'add') {
Track new Expense
@if (!expense()) {
<mat-card-title>Add new Expense</mat-card-title>
<button matIconButton [disabled]="!formDirty()" (click)="resetAddClick()"><mat-icon>replay</mat-icon></button>
<button matIconButton [disabled]="!formValid()" (click)="addClick()"><mat-icon>add</mat-icon></button>
} @else {
Edit Expense
@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 (click)="cancelUpdateClick()"><mat-icon>undo</mat-icon></button>
<button matIconButton [disabled]="!formValid()" (click)="updateClick()"><mat-icon>save</mat-icon></button>
}
}
</mat-card-title>
<button matButton="tonal" [disabled]="!enableSaveButton()" (click)="saveClick()">Save</button>
</div>
}
@if (state() === 'view') {
<div class="expense-header">
<mat-card-title>
{{ expense()?.date?.toString() | date }}: {{ expense()?.cents! / 100 | currency}}
</mat-card-title>
<button matIconButton (click)="editClick()">
<mat-icon>edit</mat-icon>
</button>
</div>
}
</mat-card-header>
<mat-card-content>
<!-- @if (state() === 'add' || state() === 'edit') {-->
<div class="expense-content">
<mat-form-field appearance="outline">
<mat-label>Date</mat-label>
@if (state() === 'view') {
<!-- <input matInput disabled="true" [matDatepicker]="expenseDatePicker" [placeholder]="expense()?.year! + '-' + expense()?.month! + '-' + expense()?.day!">-->
<input matInput disabled="true" [matDatepicker]="expenseDatePicker" [placeholder]="'2026-12-03'" [value]="expenseForm.date().value()">
} @else {
<input matInput [matDatepicker]="expenseDatePicker" [formField]="expenseForm.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]="expenseForm.cents">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Category</mat-label>
<input type="text" matInput [matAutocomplete]="categoryAuto" [formField]="expenseForm.category" [value]="expenseForm.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]="expenseForm.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]="expenseForm.note"></textarea>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tags</mat-label>
<mat-select disableRipple multiple [formField]="expenseForm.tags">
@for (tag of tags(); track tag.id) {
<mat-option [value]="tag">{{ tag.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- }-->
<!-- @if (state() === 'view') {-->
<!-- <div class="expense-content">-->
<!-- <mat-form-field appearance="outline">-->
<!-- <input matInput disabled="true" [matDatepicker]="expenseDatePicker" [placeholder]="expense()?.year! + '-' + expense()?.month! + '-' + expense()?.day!">-->
<!-- <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 disabled="true" [value]="expense()?.cents">-->
<!-- </mat-form-field>-->
<!-- <mat-form-field appearance="outline">-->
<!-- <mat-label>Category</mat-label>-->
<!-- <input type="text" matInput [matAutocomplete]="categoryAuto" [value]="expense()?.category!.name" disabled="true">-->
<!-- <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" [value]="expense()?.merchant?.name ?? 'N/A'" disabled="true">-->
<!-- <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" class="view-note">-->
<!-- <mat-label>Note</mat-label>-->
<!-- <textarea rows="1" matInput disabled="true" [value]="expense()?.note ?? 'N/A'"></textarea>-->
<!-- </mat-form-field>-->
<!-- <div class="view-tags">-->
<!-- <mat-card-subtitle>Tags:</mat-card-subtitle>-->
<!-- @if (!expense()?.tags?.length) {-->
<!-- N/A-->
<!-- }-->
<!-- @for (tag of expense()?.tags; track tag.id) {-->
<!-- <mat-chip>{{ tag.name }}</mat-chip>-->
<!-- }-->
<!-- </div>-->
<!-- </div>-->
<!-- }-->
<app-expense-form [expense]="expense()"
[disabled]="!!expense() && !editingExpense()"
(valid)="formValid.set($event)"
(dirty)="formDirty.set($event)" />
</mat-card-content>
</mat-card>
</div>

View file

@ -17,49 +17,6 @@ mat-card-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
}
.expense-content {
display: grid;
}
mat-form-field {
width: 100%;
}
.view-tags {
display: flex;
gap: 0.5rem;
align-items: center;
}
@media (min-width: 550px) {
.expense-content {
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.view-note {
grid-column: 1 / span 2;
}
.view-tags {
grid-column: 1 / span 2;
}
}
@media (min-width: 800px) {
.expense-content {
grid-template-columns: 1fr 1fr 1fr;
}
.view-note {
grid-column: 2 / span 2;
}
.view-tags {
grid-column: 1 / span 3;
}
}

View file

@ -1,98 +1,49 @@
import { Component, computed, input, signal } from '@angular/core';
import { Category, CategoryService } from '../../services/category.service';
import { Merchant, MerchantService } from '../../services/merchant.service';
import { Tag, TagService } from '../../services/tag.service';
import { form, FormField, min, required } from '@angular/forms/signals';
import { Component, input, model, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { provideNativeDateAdapter } from '@angular/material/core';
import { CreateExpense, Expense, ExpenseService } from '../../services/expense.service';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { Expense, ExpenseService } from '../../services/expense.service';
import { MatIcon } from '@angular/material/icon';
import { Temporal } from '@js-temporal/polyfill';
// import { MatChip } from '@angular/material/chips';
interface ExpenseForm {
date: Date | string;
cents: number;
category: Category | string;
merchant: Merchant | string;
note: string;
tags: Tag[];
}
import { ExpenseFormComponent } from './expense-form/expense-form.component';
import { CurrencyPipe, DatePipe } from '@angular/common';
@Component({
selector: 'app-expense',
imports: [
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatCardModule,
MatAutocompleteModule,
MatSelectModule,
MatButtonModule,
FormField,
DatePipe,
MatIcon,
CurrencyPipe,
// MatChip
],
providers: [provideNativeDateAdapter(), DatePipe],
imports: [MatCardModule, MatButtonModule, MatIcon, ExpenseFormComponent, DatePipe, CurrencyPipe],
providers: [],
templateUrl: './expense.component.html',
styleUrl: './expense.component.scss',
})
export class ExpenseComponent {
public expense = input<Expense>();
protected expenseMode = signal<'view' | 'edit'>('view');
protected state = computed<'add' | 'view' | 'edit'>(() => this.expense() ? this.expenseMode() : 'add');
protected saving = signal(false);
protected categories = computed(() => this.categoryService.categories());
protected merchants = computed(() => this.merchantService.merchants());
protected tags = computed(() => this.tagService.tags());
protected enableSaveButton = computed(() => {
const dateValid = this.expenseForm.date().valid();
const centsValid = this.expenseForm.cents().valid();
const categoryValid = this.expenseForm.category().valid();
const merchantValid = this.expenseForm.merchant().valid();
const noteValid = this.expenseForm.note().valid();
return dateValid && centsValid && categoryValid && merchantValid && noteValid && !this.saving();
});
public editingExpense = signal(false);
public formValid = model(false);
public formDirty = model(false);
// private lastSelectedDate: Date | undefined;
// private expenseDate = computed(() => {
// return this.expense()
// ? new Date(`${this.expense()?.year}-${this.expense()?.month}-${this.expense()?.day}`)
// : this.lastSelectedDate ?? '';
// });
private defaultForm: ExpenseForm = {
date: '',
cents: this.expense()?.cents ?? NaN,
category: this.expense()?.category ?? '',
merchant: this.expense()?.merchant ?? '',
note: this.expense()?.note ?? '',
tags: this.expense()?.tags ?? []
};
private expenseModel = signal<ExpenseForm>(this.defaultForm);
public expenseForm = form(this.expenseModel, (schema) => {
required(schema.date);
required(schema.cents);
min(schema.cents, 1);
required(schema.category);
});
public constructor(private readonly expenseService: ExpenseService) { }
public constructor(private readonly categoryService: CategoryService,
private readonly merchantService: MerchantService,
private readonly tagService: TagService,
private readonly expenseService: ExpenseService,
private readonly datePipe: DatePipe) { }
public editClick(): void {
this.editingExpense.set(true);
}
public async addClick(): Promise<void> {
console.log('Adding new expense');
}
public resetAddClick(): void {
console.log('Reset Add expense form');
}
public async updateClick(): Promise<void> {
console.log('Updating expense')
this.editingExpense.set(false);
}
public cancelUpdateClick(): void {
console.log('Canceling update');
this.editingExpense.set(false);
}
public async saveClick(): Promise<void> {
// const saveExpenseModel = this.expenseModel();
// const date = this.datePipe.transform(saveExpenseModel.date, 'yyyy-MM-dd')?.split('-') ?? [];
// const expense: CreateExpense = {
@ -120,12 +71,3 @@ export class ExpenseComponent {
// this.saving.set(false);
// }
}
public editClick(): void {
this.expenseMode.set('edit');
}
public autocompleteDisplay(value: Merchant | Category) {
return value.name ?? null;
}
}

View file

@ -1,4 +1,4 @@
<div class="expenses-container">
<app-expense />
<!-- <app-expense-list />-->
<app-expense-list />
</div>

View file

@ -9,8 +9,6 @@ export class CategoryService {
public readonly categories = this.internalCategories.asReadonly();
public readonly categoryPath = 'http://localhost:3000/common-cents/categories';
// public categories = signal<Category[]>([]);
public constructor(private readonly http: HttpService) {
void this.fetchCategories();
}

View file

@ -19,15 +19,23 @@ export class ExpenseService {
public async fetchExpenses(): Promise<void> {
this.internalExpenses.set(await this.http.get(this.expensePath));
console.log({ expenses: this.internalExpenses() }); // TODO: Remove
}
public async postExpense(expense: CreateExpense): Promise<Expense> {
// const createdExpense = await this.http.post<Expense>(this.expensePath, expense);
console.log(expense);
public async postExpense(createExpense: CreateExpense): Promise<Expense> {
const createdExpense = await this.http.post<Expense>(this.expensePath, createExpense);
await this.fetchExpenses();
console.log({ createExpense, createdExpense}); // TODO: Remove
// return createdExpense;
return { ...expense, id: '', category: { id: '', name: ''}, tags: [] };
return createdExpense;
}
public async updateExpense(updateExpense: UpdateExpense): Promise<Expense> {
const updatedExpense = await this.http.put<Expense>(this.expensePath, updateExpense);
await this.fetchExpenses();
console.log({ updateExpense, updatedExpense}); // TODO: Remove
return updatedExpense;
}
}
@ -49,3 +57,7 @@ export interface CreateExpense {
merchantId?: string;
tagIds?: string[];
}
export interface UpdateExpense extends CreateExpense {
id: string;
}

View file

@ -22,7 +22,7 @@ body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light; // TODO: choose 'light dark'
color-scheme: light dark;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these