metadata-management (#10)
Co-authored-by: Joe Arndt <jmarndt@users.noreply.github.com> Reviewed-on: #10
This commit is contained in:
parent
598377d8d0
commit
284e1cbef8
49 changed files with 585 additions and 82 deletions
|
|
@ -31,7 +31,12 @@
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
],
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": [
|
||||||
|
"src/assets/styles"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Common Cents</h1>
|
<h1>Common Cents</h1>
|
||||||
<h2>The common sense expense tracker.</h2>
|
<div class="app-navigation">
|
||||||
|
@for (link of navLinks(); track link) {
|
||||||
|
<a matButton [href]="link.href">{{ link.text }}</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<app-divider />
|
<app-divider />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { ExpensePage } from './pages/expenses/expense-page.component';
|
import { ExpensePage } from './pages/expenses/expense-page.component';
|
||||||
import { HomePage } from './pages/home/home-page.component';
|
import { HomePage } from './pages/home/home-page.component';
|
||||||
|
import { MetadataPageComponent } from './pages/metadata/metadata-page.component';
|
||||||
|
import { ReportsPageComponent } from './pages/reports/reports-page.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
|
|
@ -10,5 +12,13 @@ export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: 'expenses',
|
path: 'expenses',
|
||||||
component: ExpensePage
|
component: ExpensePage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'metadata',
|
||||||
|
component: MetadataPageComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reports',
|
||||||
|
component: ReportsPageComponent
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3.125rem;
|
font-size: 2.5rem;
|
||||||
line-height: 100%;
|
line-height: 100%;
|
||||||
letter-spacing: -0.125rem;
|
letter-spacing: -0.125rem;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.app-navigation {
|
||||||
font-size: 1.2rem;
|
display: flex;
|
||||||
line-height: 100%;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
font-family: sans-serif;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,36 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { Divider } from './components/divider/divider';
|
import { DividerComponent } from './components/divider/divider.component';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
|
||||||
|
interface NavLink {
|
||||||
|
text: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, Divider],
|
imports: [RouterOutlet, DividerComponent, MatButton],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App { }
|
export class App {
|
||||||
|
public navLinks = signal<NavLink[]>([
|
||||||
|
{
|
||||||
|
text: 'Home',
|
||||||
|
href: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Expenses',
|
||||||
|
href: '/expenses'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Metadata',
|
||||||
|
href: '/metadata'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Reports',
|
||||||
|
href: '/reports'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
|
||||||
9
src/app/components/divider/divider.component.ts
Normal file
9
src/app/components/divider/divider.component.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-divider',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './divider.component.html',
|
||||||
|
styleUrl: './divider.component.scss'
|
||||||
|
})
|
||||||
|
export class DividerComponent { }
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-divider',
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './divider.html',
|
|
||||||
styleUrl: './divider.scss'
|
|
||||||
})
|
|
||||||
export class Divider { }
|
|
||||||
|
|
@ -12,9 +12,8 @@
|
||||||
p {
|
p {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
line-height: 100%;
|
line-height: 100%;
|
||||||
font-weight: 200;
|
font-weight: 400;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app-divider {
|
app-divider {
|
||||||
|
|
@ -25,5 +24,5 @@
|
||||||
.expense-list {
|
.expense-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
justify-content: center;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import { ExpenseService } from '../../services/expense.service';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { ExpenseComponent } from '../expense/expense.component';
|
import { ExpenseComponent } from '../expense/expense.component';
|
||||||
import {Divider} from '../divider/divider';
|
import { DividerComponent } from '../divider/divider.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expense-list',
|
selector: 'app-expense-list',
|
||||||
imports: [MatTableModule, MatCardModule, ExpenseComponent, Divider],
|
imports: [MatTableModule, MatCardModule, ExpenseComponent, DividerComponent],
|
||||||
templateUrl: './expense-list.component.html',
|
templateUrl: './expense-list.component.html',
|
||||||
styleUrl: './expense-list.component.scss'
|
styleUrl: './expense-list.component.scss'
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
@use "variables";
|
||||||
|
|
||||||
.expense-form-container {
|
.expense-form-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
||||||
|
|
@ -6,14 +8,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 550px) {
|
@media (min-width: variables.$mid-screen) {
|
||||||
.expense-form-container {
|
.expense-form-container {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@media (min-width: variables.$wide-screen) {
|
||||||
.expense-form-container {
|
.expense-form-container {
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,18 @@ export class ExpenseFormComponent implements OnInit {
|
||||||
private readonly merchantService: MerchantService,
|
private readonly merchantService: MerchantService,
|
||||||
private readonly tagService: TagService) {
|
private readonly tagService: TagService) {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
|
const form = this.form().value();
|
||||||
const valid = this.formValid();
|
const valid = this.formValid();
|
||||||
const dirty = this.formDirty();
|
const dirty = this.formDirty();
|
||||||
const value = this.form().value();
|
const value = {
|
||||||
|
...form,
|
||||||
|
merchant: Boolean(form.merchant) ? form.merchant : null,
|
||||||
|
note: Boolean(form.note) ? form.note : null
|
||||||
|
};
|
||||||
|
|
||||||
this.valid.emit(valid);
|
this.valid.emit(valid);
|
||||||
this.dirty.emit(dirty);
|
this.dirty.emit(dirty);
|
||||||
this.value.emit(value);
|
this.value.emit(value as ExpenseForm);
|
||||||
this.lastDate.set(value.date);
|
this.lastDate.set(value.date);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -98,8 +103,8 @@ export class ExpenseFormComponent implements OnInit {
|
||||||
date: new Date(year, month, day),
|
date: new Date(year, month, day),
|
||||||
cents: expense.cents,
|
cents: expense.cents,
|
||||||
category: expense.category,
|
category: expense.category,
|
||||||
merchant: expense.merchant ?? {},
|
merchant: expense.merchant ?? null,
|
||||||
note: expense.note ?? '',
|
note: expense.note,
|
||||||
tags: expense.tags ?? []
|
tags: expense.tags ?? []
|
||||||
} as ExpenseForm;
|
} as ExpenseForm;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
@use "variables";
|
||||||
|
|
||||||
.expense-container {
|
.expense-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -5,7 +7,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
mat-card {
|
mat-card {
|
||||||
max-width: 800px;
|
max-width: variables.$wide-screen;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,16 @@ export class ExpenseComponent {
|
||||||
const postExpense: CreateExpense = {
|
const postExpense: CreateExpense = {
|
||||||
date: this.dateToPlainDate(form.date),
|
date: this.dateToPlainDate(form.date),
|
||||||
cents: form.cents,
|
cents: form.cents,
|
||||||
categoryId: form.category.id,
|
category: form.category,
|
||||||
note: !!form.note ? form.note : undefined,
|
merchant: form.merchant,
|
||||||
merchantId: !!form.merchant ? form.merchant.id : undefined,
|
note: form.note,
|
||||||
tagIds: form.tags.map(tag => tag.id)
|
tags: form.tags
|
||||||
};
|
};
|
||||||
this.savingExpense.set(true);
|
this.savingExpense.set(true);
|
||||||
const snackId = this.snackBar.staticBar('Tracking new expense...');
|
const snackId = this.snackBar.staticBar('Tracking new expense...');
|
||||||
try {
|
try {
|
||||||
await this.expenseService.postExpense(postExpense);
|
await this.expenseService.create(postExpense);
|
||||||
|
await this.expenseService.fetch();
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
this.snackBar.dismiss(snackId);
|
this.snackBar.dismiss(snackId);
|
||||||
this.snackBar.autoBar('Expense tracked!');
|
this.snackBar.autoBar('Expense tracked!');
|
||||||
|
|
@ -70,15 +71,15 @@ export class ExpenseComponent {
|
||||||
id: this.expense()!.id,
|
id: this.expense()!.id,
|
||||||
date: this.dateToPlainDate(form.date),
|
date: this.dateToPlainDate(form.date),
|
||||||
cents: form.cents,
|
cents: form.cents,
|
||||||
categoryId: form.category.id,
|
category: form.category,
|
||||||
note: !!form.note ? form.note : undefined,
|
merchant: form.merchant,
|
||||||
merchantId: !!form.merchant ? form.merchant.id : undefined,
|
note: form.note,
|
||||||
tagIds: form.tags.map(tag => tag.id)
|
tags: form.tags
|
||||||
};
|
};
|
||||||
this.savingExpense.set(true);
|
this.savingExpense.set(true);
|
||||||
const snackId = this.snackBar.staticBar('Updating expense...');
|
const snackId = this.snackBar.staticBar('Updating expense...');
|
||||||
try {
|
try {
|
||||||
const expense = await this.expenseService.updateExpense(putExpense);
|
const expense = await this.expenseService.update(putExpense);
|
||||||
this.expense.set(expense);
|
this.expense.set(expense);
|
||||||
this.form()?.refresh(expense);
|
this.form()?.refresh(expense);
|
||||||
this.editingExpense.set(false);
|
this.editingExpense.set(false);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<div class="categories-container">
|
||||||
|
<mat-card appearance="outlined">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Categories</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="new-category">
|
||||||
|
<app-metadata-form (newMetaData)="createCategory($event.name)" [editing]="true" [label]="'New Category'"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="category-list">
|
||||||
|
@for (category of categories(); track category.id) {
|
||||||
|
<app-metadata-form [metadata]="category" (newMetaData)="updateCategory($event)" />
|
||||||
|
}
|
||||||
|
@if (!categories().length) {
|
||||||
|
<div>
|
||||||
|
No categories to display
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
@use "variables";
|
||||||
|
|
||||||
|
.categories-container {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-card {
|
||||||
|
max-width: variables.$wide-screen;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-category {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Component, computed } from '@angular/core';
|
||||||
|
import { Category, CategoryService } from '../../../services/category.service';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MetadataFormComponent } from '../metadata-form/metadata-form.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-categories',
|
||||||
|
imports: [
|
||||||
|
MatCardModule,
|
||||||
|
MetadataFormComponent
|
||||||
|
],
|
||||||
|
templateUrl: './categories.component.html',
|
||||||
|
styleUrl: './categories.component.scss'
|
||||||
|
})
|
||||||
|
export class CategoriesComponent {
|
||||||
|
protected categories = computed(() => this.categoryService.categories());
|
||||||
|
|
||||||
|
public constructor(private readonly categoryService: CategoryService) { }
|
||||||
|
|
||||||
|
public async createCategory(name: string): Promise<void> {
|
||||||
|
await this.categoryService.create({ name });
|
||||||
|
await this.categoryService.fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateCategory(category: Category): Promise<void> {
|
||||||
|
await this.categoryService.update(category);
|
||||||
|
await this.categoryService.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<div class="merchants-container">
|
||||||
|
<mat-card appearance="outlined">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Merchants</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="new-merchant">
|
||||||
|
<app-metadata-form (newMetaData)="createMerchant($event.name)" [editing]="true" [label]="'New Merchant'"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="merchant-list">
|
||||||
|
@for (merchant of merchants(); track merchant.id) {
|
||||||
|
<app-metadata-form [metadata]="merchant" (newMetaData)="updateMerchant($event)" />
|
||||||
|
}
|
||||||
|
@if (!merchants().length) {
|
||||||
|
<div>
|
||||||
|
No merchants to display
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
@use "variables";
|
||||||
|
|
||||||
|
.merchants-container {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-card {
|
||||||
|
max-width: variables.$wide-screen;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-merchant {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
29
src/app/components/metadata/merchants/merchants.component.ts
Normal file
29
src/app/components/metadata/merchants/merchants.component.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Component, computed } from '@angular/core';
|
||||||
|
import { Merchant, MerchantService } from '../../../services/merchant.service';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MetadataFormComponent } from '../metadata-form/metadata-form.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-merchants',
|
||||||
|
imports: [
|
||||||
|
MatCardModule,
|
||||||
|
MetadataFormComponent
|
||||||
|
],
|
||||||
|
templateUrl: './merchants.component.html',
|
||||||
|
styleUrl: './merchants.component.scss'
|
||||||
|
})
|
||||||
|
export class MerchantsComponent {
|
||||||
|
protected merchants = computed(() => this.merchantService.merchants());
|
||||||
|
|
||||||
|
public constructor(private readonly merchantService: MerchantService) { }
|
||||||
|
|
||||||
|
public async createMerchant(name: string): Promise<void> {
|
||||||
|
await this.merchantService.create({ name });
|
||||||
|
await this.merchantService.fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateMerchant(merchant: Merchant): Promise<void> {
|
||||||
|
await this.merchantService.update(merchant);
|
||||||
|
await this.merchantService.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<div class="metadata-form-container">
|
||||||
|
@if (!editing()) {
|
||||||
|
<div>{{ metadata()?.name }}</div>
|
||||||
|
|
||||||
|
<button matIconButton (click)="editing.set(true)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div class="metadata-edit">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
@if (label()) {
|
||||||
|
<mat-label>{{ label() }}</mat-label>
|
||||||
|
}
|
||||||
|
<input type="text" matInput [(ngModel)]="name">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="metadata-edit-buttons">
|
||||||
|
<button matIconButton (click)="reset()">
|
||||||
|
<mat-icon>undo</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button matIconButton (click)="saveMetaData()" [disabled]="!nameValid()">
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
.metadata-form-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-edit {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.metadata-edit-buttons {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Component, computed, input, model, OnInit, output } from '@angular/core';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
interface MetaData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-metadata-form',
|
||||||
|
imports: [
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatInputModule,
|
||||||
|
FormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './metadata-form.component.html',
|
||||||
|
styleUrl: './metadata-form.component.scss'
|
||||||
|
})
|
||||||
|
export class MetadataFormComponent implements OnInit {
|
||||||
|
public editing = model(false);
|
||||||
|
public label = input<string>();
|
||||||
|
public metadata = input<MetaData>();
|
||||||
|
public newMetaData = output<MetaData>();
|
||||||
|
|
||||||
|
public name = model('');
|
||||||
|
public nameValid = computed(() => {
|
||||||
|
const existingName = this.metadata()?.name;
|
||||||
|
const newName = this.name();
|
||||||
|
|
||||||
|
return !!newName && newName !== existingName;
|
||||||
|
});
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public saveMetaData(): void {
|
||||||
|
this.newMetaData.emit({
|
||||||
|
...this.metadata()!,
|
||||||
|
name: this.name()
|
||||||
|
});
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.name.set(this.metadata()?.name ?? '');
|
||||||
|
this.editing.set(!this.metadata());
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/components/metadata/metadata.component.html
Normal file
36
src/app/components/metadata/metadata.component.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<div class="metadata-container">
|
||||||
|
<mat-tab-group>
|
||||||
|
<mat-tab>
|
||||||
|
<ng-template mat-tab-label>
|
||||||
|
<div class="metadata-tab">
|
||||||
|
<div class="tab-text">Categories</div>
|
||||||
|
<mat-icon class="material-icons-outlined">dashboard</mat-icon>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<app-categories />
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<mat-tab>
|
||||||
|
<ng-template mat-tab-label>
|
||||||
|
<div class="metadata-tab">
|
||||||
|
<div class="tab-text">Merchants</div>
|
||||||
|
<mat-icon class="material-icons-outlined">shopping_cart</mat-icon>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<app-merchants />
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<mat-tab>
|
||||||
|
<ng-template mat-tab-label>
|
||||||
|
<div class="metadata-tab">
|
||||||
|
<div class="tab-text">Tags</div>
|
||||||
|
<mat-icon class="material-icons-outlined">sell</mat-icon>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<app-tags />
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
|
</div>
|
||||||
17
src/app/components/metadata/metadata.component.scss
Normal file
17
src/app/components/metadata/metadata.component.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
@use "variables";
|
||||||
|
|
||||||
|
.metadata-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: variables.$mid-screen) {
|
||||||
|
.tab-text {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/components/metadata/metadata.component.ts
Normal file
22
src/app/components/metadata/metadata.component.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { MatTab, MatTabGroup, MatTabLabel } from '@angular/material/tabs';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { TagsComponent } from './tags/tags.component';
|
||||||
|
import { MerchantsComponent } from './merchants/merchants.component';
|
||||||
|
import { CategoriesComponent } from './categories/categories.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-metadata',
|
||||||
|
imports: [
|
||||||
|
MatTabGroup,
|
||||||
|
MatTab,
|
||||||
|
MatIcon,
|
||||||
|
MatTabLabel,
|
||||||
|
TagsComponent,
|
||||||
|
MerchantsComponent,
|
||||||
|
CategoriesComponent
|
||||||
|
],
|
||||||
|
templateUrl: './metadata.component.html',
|
||||||
|
styleUrl: './metadata.component.scss'
|
||||||
|
})
|
||||||
|
export class MetadataComponent { }
|
||||||
24
src/app/components/metadata/tags/tags.component.html
Normal file
24
src/app/components/metadata/tags/tags.component.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<div class="tags-container">
|
||||||
|
<mat-card appearance="outlined">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Tags</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="new-tag">
|
||||||
|
<app-metadata-form (newMetaData)="createTag($event.name)" [editing]="true" [label]="'New Tag'"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tag-list">
|
||||||
|
@for (tag of tags(); track tag.id) {
|
||||||
|
<app-metadata-form [metadata]="tag" (newMetaData)="updateTag($event)" />
|
||||||
|
}
|
||||||
|
@if (!tags().length) {
|
||||||
|
<div>
|
||||||
|
No tags to display
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
21
src/app/components/metadata/tags/tags.component.scss
Normal file
21
src/app/components/metadata/tags/tags.component.scss
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
@use "variables";
|
||||||
|
|
||||||
|
.tags-container {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-card {
|
||||||
|
max-width: variables.$wide-screen;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-tag {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
29
src/app/components/metadata/tags/tags.component.ts
Normal file
29
src/app/components/metadata/tags/tags.component.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Component, computed } from '@angular/core';
|
||||||
|
import { Tag, TagService } from '../../../services/tag.service';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MetadataFormComponent } from '../metadata-form/metadata-form.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tags',
|
||||||
|
imports: [
|
||||||
|
MatCardModule,
|
||||||
|
MetadataFormComponent
|
||||||
|
],
|
||||||
|
templateUrl: './tags.component.html',
|
||||||
|
styleUrl: './tags.component.scss'
|
||||||
|
})
|
||||||
|
export class TagsComponent {
|
||||||
|
protected tags = computed(() => this.tagService.tags());
|
||||||
|
|
||||||
|
public constructor(private readonly tagService: TagService) { }
|
||||||
|
|
||||||
|
public async createTag(name: string): Promise<void> {
|
||||||
|
await this.tagService.create({ name });
|
||||||
|
await this.tagService.fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateTag(tag: Tag): Promise<void> {
|
||||||
|
await this.tagService.update(tag);
|
||||||
|
await this.tagService.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<div class="expenses-container">
|
<div class="expenses-page-container">
|
||||||
<app-expense />
|
<app-expense />
|
||||||
<app-expense-list />
|
<app-expense-list />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
.expenses-container {
|
.expenses-page-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { ExpenseListComponent } from '../../components/expense-list/expense-list
|
||||||
import { ExpenseComponent } from '../../components/expense/expense.component';
|
import { ExpenseComponent } from '../../components/expense/expense.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expenses',
|
selector: 'app-expenses-page',
|
||||||
imports: [
|
imports: [
|
||||||
ExpenseListComponent,
|
ExpenseListComponent,
|
||||||
ExpenseComponent
|
ExpenseComponent
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
<div class="home-container">
|
<div class="home-page-container">
|
||||||
<ul>
|
<h2>The common sense expense tracker.</h2>
|
||||||
<li>
|
|
||||||
<a routerLink="expenses">ExpenseService</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 100%;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home-page',
|
||||||
imports: [
|
imports: [],
|
||||||
RouterLink
|
|
||||||
],
|
|
||||||
templateUrl: './home-page.component.html',
|
templateUrl: './home-page.component.html',
|
||||||
styleUrl: './home-page.component.scss',
|
styleUrl: './home-page.component.scss'
|
||||||
})
|
})
|
||||||
export class HomePage { }
|
export class HomePage { }
|
||||||
|
|
|
||||||
3
src/app/pages/metadata/metadata-page.component.html
Normal file
3
src/app/pages/metadata/metadata-page.component.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="metadata-page-container">
|
||||||
|
<app-metadata />
|
||||||
|
</div>
|
||||||
0
src/app/pages/metadata/metadata-page.component.scss
Normal file
0
src/app/pages/metadata/metadata-page.component.scss
Normal file
12
src/app/pages/metadata/metadata-page.component.ts
Normal file
12
src/app/pages/metadata/metadata-page.component.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { MetadataComponent } from '../../components/metadata/metadata.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-metadata-page',
|
||||||
|
imports: [
|
||||||
|
MetadataComponent
|
||||||
|
],
|
||||||
|
templateUrl: './metadata-page.component.html',
|
||||||
|
styleUrl: './metadata-page.component.scss'
|
||||||
|
})
|
||||||
|
export class MetadataPageComponent { }
|
||||||
3
src/app/pages/reports/reports-page.component.html
Normal file
3
src/app/pages/reports/reports-page.component.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="reports-page-container">
|
||||||
|
Reports page
|
||||||
|
</div>
|
||||||
0
src/app/pages/reports/reports-page.component.scss
Normal file
0
src/app/pages/reports/reports-page.component.scss
Normal file
9
src/app/pages/reports/reports-page.component.ts
Normal file
9
src/app/pages/reports/reports-page.component.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reports-page',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './reports-page.component.html',
|
||||||
|
styleUrl: './reports-page.component.scss'
|
||||||
|
})
|
||||||
|
export class ReportsPageComponent { }
|
||||||
|
|
@ -10,15 +10,27 @@ export class CategoryService {
|
||||||
public readonly categoryPath = 'http://localhost:3000/common-cents/categories';
|
public readonly categoryPath = 'http://localhost:3000/common-cents/categories';
|
||||||
|
|
||||||
public constructor(private readonly http: HttpService) {
|
public constructor(private readonly http: HttpService) {
|
||||||
void this.fetchCategories();
|
void this.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fetchCategories(): Promise<void> {
|
public async fetch(): Promise<void> {
|
||||||
this.internalCategories.set(await this.http.get<Category[]>(this.categoryPath));
|
this.internalCategories.set(await this.http.get<Category[]>(this.categoryPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async create(category: CreateCategory): Promise<Category> {
|
||||||
|
return await this.http.post<Category>(this.categoryPath, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(category: Category): Promise<Category> {
|
||||||
|
return await this.http.put<Category>(this.categoryPath, category);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCategory {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,18 @@ export class ExpenseService {
|
||||||
public readonly expensePath = 'http://localhost:3000/common-cents/expenses'; // TODO: refactor
|
public readonly expensePath = 'http://localhost:3000/common-cents/expenses'; // TODO: refactor
|
||||||
|
|
||||||
public constructor(private readonly http: HttpService) {
|
public constructor(private readonly http: HttpService) {
|
||||||
void this.fetchExpenses();
|
void this.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fetchExpenses(): Promise<void> {
|
public async fetch(): Promise<void> {
|
||||||
this.internalExpenses.set(await this.http.get(this.expensePath));
|
this.internalExpenses.set(await this.http.get(this.expensePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async postExpense(createExpense: CreateExpense): Promise<Expense> {
|
public async create(createExpense: CreateExpense): Promise<Expense> {
|
||||||
const createdExpense = await this.http.post<Expense>(this.expensePath, createExpense);
|
return await this.http.post<Expense>(this.expensePath, createExpense);
|
||||||
await this.fetchExpenses();
|
|
||||||
|
|
||||||
return createdExpense;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateExpense(updateExpense: UpdateExpense): Promise<Expense> {
|
public async update(updateExpense: UpdateExpense): Promise<Expense> {
|
||||||
return await this.http.put<Expense>(this.expensePath, updateExpense);
|
return await this.http.put<Expense>(this.expensePath, updateExpense);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -38,18 +35,18 @@ export interface Expense {
|
||||||
date: Date;
|
date: Date;
|
||||||
cents: number;
|
cents: number;
|
||||||
category: Category;
|
category: Category;
|
||||||
note?: string;
|
|
||||||
merchant?: Merchant;
|
merchant?: Merchant;
|
||||||
|
note?: string;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateExpense {
|
export interface CreateExpense {
|
||||||
date: Temporal.PlainDate;
|
date: Temporal.PlainDate;
|
||||||
cents: number;
|
cents: number;
|
||||||
categoryId: string;
|
category: Category;
|
||||||
|
merchant?: Merchant;
|
||||||
note?: string;
|
note?: string;
|
||||||
merchantId?: string;
|
tags: Tag[];
|
||||||
tagIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateExpense extends CreateExpense {
|
export interface UpdateExpense extends CreateExpense {
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,27 @@ export class MerchantService {
|
||||||
public readonly merchantPath = 'http://localhost:3000/common-cents/merchants';
|
public readonly merchantPath = 'http://localhost:3000/common-cents/merchants';
|
||||||
|
|
||||||
public constructor(private readonly http: HttpService) {
|
public constructor(private readonly http: HttpService) {
|
||||||
void this.fetchMerchants();
|
void this.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fetchMerchants(): Promise<void> {
|
public async fetch(): Promise<void> {
|
||||||
this.internalMerchants.set(await this.http.get<Merchant[]>(this.merchantPath));
|
this.internalMerchants.set(await this.http.get<Merchant[]>(this.merchantPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async create(merchant: CreateMerchant): Promise<Merchant> {
|
||||||
|
return await this.http.post<Merchant>(this.merchantPath, merchant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(merchant: Merchant): Promise<Merchant> {
|
||||||
|
return await this.http.put<Merchant>(this.merchantPath, merchant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Merchant {
|
export interface Merchant {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateMerchant {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,27 @@ export class TagService {
|
||||||
public readonly tagPath = 'http://localhost:3000/common-cents/tags';
|
public readonly tagPath = 'http://localhost:3000/common-cents/tags';
|
||||||
|
|
||||||
public constructor(private readonly http: HttpService) {
|
public constructor(private readonly http: HttpService) {
|
||||||
void this.fetchTags();
|
void this.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fetchTags(): Promise<void> {
|
public async fetch(): Promise<void> {
|
||||||
this.internalTags.set(await this.http.get<Tag[]>(this.tagPath));
|
this.internalTags.set(await this.http.get<Tag[]>(this.tagPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async create(tag: CreateTag): Promise<Tag> {
|
||||||
|
return await this.http.post<Tag>(this.tagPath, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(tag: Tag): Promise<Tag> {
|
||||||
|
return await this.http.put<Tag>(this.tagPath, tag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateTag {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
2
src/assets/styles/variables.scss
Normal file
2
src/assets/styles/variables.scss
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
$mid-screen: 500px;
|
||||||
|
$wide-screen: 800px;
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@include mat.theme((
|
@include mat.theme((
|
||||||
color: (
|
color: (
|
||||||
primary: mat.$cyan-palette,
|
primary: mat.$azure-palette,
|
||||||
tertiary: mat.$orange-palette,
|
tertiary: mat.$orange-palette,
|
||||||
),
|
),
|
||||||
typography: Roboto,
|
typography: Roboto,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue