Angularのサンプルプロジェクト「家計簿アプリ (KakeiboApp)」について

Angularのサンプルプロジェクトについて

Angularのサンプルプロジェクトについて

この記事では、Angular CLI バージョン18.1.0で生成された「家計簿アプリ (KakeiboApp)」プロジェクトの内容を詳細に解説します。このアプリは、ユーザーが収入と支出を記録し、現在の残高や取引履歴を確認したり、支出の分類別グラフを表示したりできるシンプルな家計簿アプリケーションです。
プロジェクト全体はGithubにアップロードしておりますので、合わせて参考にしてください。
https://github.com/flying-YT/kakeibo-app/tree/main

なお、本アプリをベースに演習問題を作成してみました。Angularに興味ある方は是非試してみてください。
実践!手を動かして学ぶAngular演習問題


プロジェクト概要

本家計簿アプリは、Angularのコンポーネントとサービスを効果的に利用し、リアクティブなデータ管理にはAngularのSignal機能を採用しています。また、グラフ表示にはChart.jsとng2-chartsライブラリが活用されています。アプリケーションの全体的なスタイルはsrc/styles.cssで定義されており、カードスタイル、タイポグラフィ、テーブル、ボタン、フォーム、ナビゲーションなどの共通スタイルが含まれています。



主要コンポーネントの紹介

Angularアプリケーションは、独立した機能を持つコンポーネントによって構成されます。本プロジェクトの主要なコンポーネントとその役割について説明します。


AppComponent: アプリケーションの骨格

AppComponentは、アプリケーション全体のレイアウトを定義するルートコンポーネントです。

  • 目的: アプリケーションのタイトル、メインナビゲーション、そしてルーティングに基づいて各画面のコンポーネントが表示されるメインエリアを提供します [7]。また、アプリケーション全体で利用されるトースト通知コンポーネントもここに配置されます。
  • HTML (app.component.html):
    <div class="container">
      <h1>家計簿アプリ</h1>
    
      <nav>
        <ul>
          <li><a routerLink="/balance" routerLinkActive="active">残高・履歴</a></li>
          <li><a routerLink="/chart" routerLinkActive="active">分類グラフ</a></li>
        </ul>
      </nav>
    
      <main>
        <!-- この部分に各画面のコンポーネントが表示される -->
        <router-outlet></router-outlet>
      </main>
    </div>
    <app-toast></app-toast>
    • <h1>家計簿アプリ</h1>: アプリケーションのタイトルが表示されます。
    • <nav>: 「残高・履歴」と「分類グラフ」の2つのナビゲーションリンクがあり、それぞれ/balance/chartルートに対応しています。routerLinkActive="active"により、現在アクティブなリンクにスタイルが適用されます。
    • <router-outlet></router-outlet>: ルーティングに基づいて対応するコンポーネント(BalanceComponent, TransactionFormComponent, ChartComponentなど)がこの部分に動的に表示されます。
    • <app-toast></app-toast>: アプリケーション全体で利用されるトースト通知機能を提供します。
  • TypeScript (app.component.ts):
    import { Component } from '@angular/core';
    import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
    import { ToastComponent } from './components/toast/toast.component'; 
    import { CommonModule } from '@angular/common';
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, ToastComponent],
      templateUrl: './app.component.html',
      styleUrl: './app.component.css'
    })
    export class AppComponent {
      title = 'kakeibo-app';
    }
    • @Componentデコレータでselector: 'app-root'standalone: trueが設定されています [6]。
    • imports配列には、ルーティング関連のモジュール(RouterLink, RouterLinkActive, RouterOutlet)と、ToastComponent、およびCommonModuleが含まれており、これらの機能をコンポーネント内で利用可能にしています。
    • title = 'kakeibo-app'というプロパティを持ちますが、HTMLでは<h1>家計簿アプリ</h1>と固定テキストが表示されています。
  • CSS (style.css): このファイルはナビゲーションとコンテナの基本的なレイアウトを定義しています。
  • /* General Styles */
    body {
        font-family: 'Helvetica Neue', Arial, sans-serif;
        background-color: #f4f7f6;
        color: #333;
        margin: 0;
        padding: 20px;
    }
      
    .container {
        max-width: 960px;
        margin: 0 auto;
        padding: 20px;
    }
      
    /* Card Style */
    .card {
        background-color: #ffffff;
        border-radius: 8px;
        box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
        padding: 24px;
        margin-bottom: 20px;
    }
      
    /* Typography */
    h1, h2, h3 {
        color: #2c3e50;
        margin-top: 0;
    }
    
    h1 {
        font-size: 2.5rem;
        border-bottom: 2px solid #3498db;
        padding-bottom: 10px;
        margin-bottom: 20px;
    }
    
    h2 {
        font-size: 1.8rem;
        color: #34495e;
    }
    
    /* Table Styles */
    table {
        width: 100%;
        border-collapse: collapse;
        margin-top: 20px;
    }
    
    th, td {
        padding: 12px 15px;
        text-align: left;
        border-bottom: 1px solid #ddd;
    }
    
    thead th {
        background-color: #3498db;
        color: #ffffff;
        font-weight: bold;
    }
    
    tbody tr:nth-child(even) {
        background-color: #f2f2f2;
    }
    
    tbody tr:hover {
        background-color: #eaf2f8;
    }
    
    /* Button Styles */
    .btn {
        display: inline-block;
        padding: 10px 20px;
        border: none;
        border-radius: 5px;
        color: #fff;
        background-color: #3498db;
        cursor: pointer;
        font-size: 1rem;
        text-align: center;
        text-decoration: none;
        transition: background-color 0.3s ease;
    }
    
    .btn:hover {
        background-color: #2980b9;
    }
    
    .btn-primary {
        background-color: #2ecc71;
      }
      .btn-primary:hover {
        background-color: #27ae60;
    }
    
    /* Form Styles */
    form {
        display: flex;
        flex-direction: column;
        gap: 15px;
    }
    
    label {
        font-weight: bold;
        margin-bottom: 5px;
    }
      
    input[type="text"],
    input[type="date"],
    input[type="number"],
    select {
    width: 100%;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    box-sizing: border-box; /* Important for padding and width */
    }
      
    /* Navigation */
    nav {
        background-color: #2c3e50;
        padding: 10px 0;
        margin-bottom: 20px;
    }
    nav ul {
        list-style: none;
        display: flex;
        justify-content: center;
        margin: 0;
        padding: 0;
    }
    nav ul li a {
        color: white;
        text-decoration: none;
        padding: 10px 20px;
        display: block;
        border-radius: 5px;
    }
    nav ul li a:hover,
    nav ul li a.active {
    background-color: #34495e;
    }

BalanceComponent: 残高と取引履歴

現在の残高と、これまでの取引履歴一覧を表示するコンポーネントです。

  • 目的: ユーザーが現在の資産状況と過去の取引を把握できるようにします。
  • HTML (balance.component.html):
    <div class="card">
        <h2>現在の残高</h2>
        <p class="balance">{{ balance() | currency:'JPY':'symbol':'1.0-0' }}</p>
    </div>
    
    <div class="card">
    <div class="header">
        <h2>取引履歴</h2>
        <a routerLink="/add" class="btn btn-primary">取引を登録する</a>
    </div>
    <table>
        <thead>
        <tr>
            <th>日付</th>
            <th>内容</th>
            <th>分類</th>
            <th>金額</th>
        </tr>
        </thead>
            <tbody>
            @for (transaction of transactions(); track transaction.id) {
                <tr>
                    <td>{{ transaction.date | date:'yyyy/MM/dd' }}</td>
                    <td>{{ transaction.description }}</td>
                    <td>{{ transaction.category }}</td>
                    <!-- 収支によってクラスと符号を動的に変更 -->
                    <td class="amount" [ngClass]="transaction.type === 'income' ? 'income' : 'expense'">
                        {{ transaction.type === 'income' ? '+' : '-' }}{{ transaction.amount | currency:'JPY':'symbol':'1.0-0' }}
                    </td>
                </tr>
            } @empty {
                <tr>
                    <td colspan="4">取引履歴はありません。</td>
                </tr>
            }
            </tbody>
        </table>
    </div>
    • 「現在の残高」カード: balance() Signalから取得した現在の残高を日本円の通貨形式(記号付き、小数点以下なし)で表示します。
    • 「取引履歴」カード:
      • 「取引を登録する」ボタンがあり、/addルートへのリンクとなっています。
      • 取引履歴をテーブル形式で表示します。各行には日付、内容、分類、金額が含まれます。
      • 金額は、取引タイプが「income(収入)」であれば「青色」のプラス記号、タイプが「expense(支出)」であれば「赤色」のマイナス記号で表示されるように、[ngClass]ディレクティブで動的にスタイルが適用されます。
      • 取引がない場合は「取引履歴はありません。」と表示されます。
  • TypeScript (balance.component.ts):
    import { Component, inject } from '@angular/core'; // inject をインポート
    import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
    import { RouterLink } from '@angular/router';
    import { TransactionService } from '../../services/transaction.service';
    
    @Component({
      selector: 'app-balance',
      standalone: true,
      imports: [CommonModule, CurrencyPipe, DatePipe, RouterLink],
      templateUrl: './balance.component.html',
      styleUrl: './balance.component.css'
    })
    export class BalanceComponent {
      // inject() を使ってサービスを注入
      private transactionService = inject(TransactionService);
    
      // 注入したサービスインスタンスを使ってプロパティを初期化
      balance = this.transactionService.balance;
      transactions = this.transactionService.transactions;
    
      // コンストラクタは空でもOK(他に処理がなければ削除しても良い)
      constructor() {}
    }
    • TransactionServiceをAngularのinject()関数で注入しています。
    • 注入したサービスから、balance = this.transactionService.balance;transactions = this.transactionService.transactions;として、コンポーネントのプロパティとしてSignalを直接公開しています。これにより、サービスのデータ変更が自動的にビューに反映されます。
    • 必要なモジュールとしてCommonModuleCurrencyPipe(通貨表示用)、DatePipe(日付フォーマット用)、RouterLinkがインポートされています。
  • CSS (balance.component.css): 残高の文字サイズ・太さ、ヘッダーの配置、金額(収入は青、支出は赤)の色などを定義しています。
  • .balance {
        font-size: 2.5rem;
        font-weight: bold;
        color: #2c3e50;
        text-align: right;
    }
    
    .header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 1rem;
    }
    
    .amount {
        text-align: right;
        font-weight: bold;
    }
    
    /* 支出(赤色) */
      .amount.expense {
        color: #e74c3c;
    }
    
    /* 収入(青色) */
    .amount.income {
        color: #3498db;
    } 

TransactionFormComponent: 取引登録フォーム

新しい取引(収入または支出)を登録するためのフォームを提供するコンポーネントです。

  • 目的: ユーザーが簡単に取引情報を入力し、家計簿に追加できるようにします。
  • HTML (transaction-form.component.html):
    <div class="card">
        <h2>新しい取引を登録</h2>
        <form [formGroup]="transactionForm" (ngSubmit)="onSubmit()">
      
          <!-- 収支区分選択 -->
          <div class="form-group">
            <label>収支区分</label>
            <div class="radio-group">
              <label>
                <input type="radio" value="expense" formControlName="type"> 支出
              </label>
              <label>
                <input type="radio" value="income" formControlName="type"> 収入
              </label>
            </div>
          </div>
      
          <div class="form-group">
            <label for="date">取引日</label>
            <input type="date" id="date" formControlName="date">
          </div>
      
          <div class="form-group">
            <label for="description">内容</label>
            <input type="text" id="description" formControlName="description" placeholder="例: スーパーでの買い物">
          </div>
      
          <div class="form-group">
            <label for="amount">金額</label>
            <input type="number" id="amount" formControlName="amount" placeholder="例: 3500">
          </div>
      
          <!-- 分類選択 -->
          <div class="form-group">
            <label for="category">分類</label>
            <select id="category" formControlName="category">
              @for (category of currentCategories; track category) {
                <option [value]="category">{{ category }}</option>
              }
            </select>
          </div>
      
          <div class="form-actions">
            <button type="submit" class="btn btn-primary">登録する</button>
            <a routerLink="/balance" class="btn">キャンセル</a>
          </div>
        </form>
    </div>
    • Angularのリアクティブフォーム([formGroup]="transactionForm")を使用しています。
    • 入力項目:
      • 収支区分 (type): 「支出」と「収入」のラジオボタンで選択します。
      • 取引日 (date): 日付入力フィールド。
      • 内容 (description): テキスト入力フィールド(例: スーパーでの買い物)。
      • 金額 (amount): 数値入力フィールド(例: 3500)。
      • 分類 (category): ドロップダウン選択リスト。収支区分(収入か支出か)に応じて選択肢が動的に変わるように設計されています。
    • 「登録する」ボタンと「キャンセル」ボタンがあります。「キャンセル」ボタンは/balanceルートへ戻るリンクです。
  • TypeScript (transaction-form.component.ts):
    import { Component, inject, OnInit } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
    import { Router, RouterLink } from '@angular/router';
    import { TransactionService, TransactionType, TransactionCategory } from '../../services/transaction.service';
    import { ToastService } from '../toast/toast.service'; // トーストサービスをインポート
    
    @Component({
      selector: 'app-transaction-form',
      standalone: true,
      imports: [CommonModule, ReactiveFormsModule, RouterLink],
      templateUrl: './transaction-form.component.html',
      styleUrl: './transaction-form.component.css'
    })
    export class TransactionFormComponent implements OnInit {
      private fb = inject(FormBuilder);
      private router = inject(Router);
      private transactionService = inject(TransactionService);
      private toastService = inject(ToastService); // トーストサービスを注入
    
      // カテゴリリスト
      expenseCategories = this.transactionService.expenseCategories;
      incomeCategories = this.transactionService.incomeCategories;
      
      // フォームの定義を更新
      transactionForm = this.fb.group({
        type: ['expense' as TransactionType, Validators.required],
        date: [this.getTodayString(), Validators.required],
        description: ['', [Validators.required, Validators.maxLength(50)]],
        amount: [null as number | null, [Validators.required, Validators.min(1)]],
        category: [this.expenseCategories[0] as TransactionCategory, Validators.required],
      });
    
      ngOnInit(): void {
        // 収支区分の変更を監視して、カテゴリの選択肢とバリデーションを動的に変更
        this.transactionForm.get('type')?.valueChanges.subscribe(type => {
          const categoryControl = this.transactionForm.get('category');
          if (type === 'income') {
            categoryControl?.setValue(this.incomeCategories[0]);
          } else {
            categoryControl?.setValue(this.expenseCategories[0]);
          }
        });
      }
    
      // 表示するカテゴリリストを動的に決定
      get currentCategories(): TransactionCategory[] {
        const type = this.transactionForm.get('type')?.value;
        return type === 'income' ? this.incomeCategories : this.expenseCategories;
      }
    
      private getTodayString(): string {
        return new Date().toISOString().split('T')[0];
      }
    
      onSubmit(): void {
        // フォームのバリデーションチェック
        if (this.transactionForm.invalid) {
          // 未入力項目をチェックしてトーストで通知
          const controls = this.transactionForm.controls;
          if (controls.description.invalid) {
            this.toastService.show('内容を入力してください。', 'error');
            console.log('内容を入力してください。');
            return;
          }
          if (controls.amount.invalid) {
            this.toastService.show('金額を1以上で入力してください。', 'error');
            console.log('金額を1以上で入力してください。');
            return;
          }
          this.toastService.show('入力内容を確認してください。', 'error');
          console.log('入力内容を確認してください。');
          return;
        }
        
        // フォームの値をサービスに渡す
        const formValue = this.transactionForm.getRawValue();
        this.transactionService.addTransaction({
          date: formValue.date!,
          description: formValue.description!,
          amount: formValue.amount!,
          type: formValue.type!,
          category: formValue.category!,
        });
        
        this.toastService.show('取引を登録しました。', 'success');
        this.router.navigate(['/balance']);
      }
    }
    • FormBuilderRouterTransactionServiceToastServiceinject()関数で注入しています。
    • transactionFormは、typedatedescriptionamountcategoryの各フィールドに対して、Validators.required(必須入力)、Validators.maxLength(50)(内容の最大文字数)、Validators.min(1)(金額が1以上)といったバリデーションルールが設定されています。
    • ngOnInit(): フォームのtypeフィールド(収支区分)の変更を監視し、選択されたタイプ(収入または支出)に応じてcategoryの選択肢(incomeCategoriesまたはexpenseCategories)と、現在の選択値を動的に更新します。
    • get currentCategories()ゲッター: 現在選択されている収支区分に基づいて、表示すべきカテゴリリストを動的に決定します。
    • getTodayString()メソッド: 今日の日付を「YYYY-MM-DD」形式の文字列で返します。
    • onSubmit()メソッド:
      • フォームのバリデーションを実行し、フォームが無効な場合はToastService.show()メソッドを使ってユーザーにエラーメッセージを表示します(例: 「内容を入力してください。」、「金額を1以上で入力してください。」)。
      • バリデーションが成功した場合、フォームの生データをTransactionService.addTransaction()メソッドに渡して新しい取引を追加します。
      • 取引追加後、「取引を登録しました。」という成功トーストを表示し、Router.navigate()を使って自動的に残高・履歴画面(/balance)に戻ります。
  • CSS (transaction-form.component.css): フォーム内の要素のレイアウト(フレックスボックス)、ラジオボタングループの配置などを定義しています。
  • .form-actions {
        display: flex;
        gap: 10px;
        margin-top: 20px;
    }
    
    .form-group {
        display: flex;
        flex-direction: column;
        gap: 5px;
    }
      
    .radio-group {
        display: flex;
        gap: 20px;
    }
      
    .radio-group label {
        display: flex;
        align-items: center;
        gap: 5px;
        font-weight: normal;
    }

ChartComponent: 分類別支出グラフ

登録された支出データを分類別に集計し、円グラフで視覚的に表示するコンポーネントです。

  • 目的: ユーザーが支出の内訳を一目で理解し、家計の傾向を把握できるようにします。
  • HTML (chart.component.html):
    <div class="card">
        <h2>分類別 支出グラフ</h2>
        @if(pieChartData.datasets[0].data.length > 0){
          <div class="chart-container">
            <canvas baseChart
              [data]="pieChartData"
              [type]="pieChartType"
              [options]="pieChartOptions">
            </canvas>
          </div>
        } @else {
          <p>表示するデータがありません。</p>
        }
    </div>
    • 「分類別 支出グラフ」というタイトルが表示されます。
    • @ifブロックを使用して、pieChartData.datasets.dataにデータが存在する場合のみ<canvas baseChart ...></canvas>要素(Chart.jsのグラフ描画エリア)を表示します。
    • データがない場合は「表示するデータがありません。」というメッセージが表示されます。
  • TypeScript (chart.component.ts):
    import { Component, OnInit } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { BaseChartDirective } from 'ng2-charts';
    import { ChartConfiguration, ChartData, ChartType } from 'chart.js';
    import { TransactionService } from '../../services/transaction.service';
    
    @Component({
      selector: 'app-chart',
      standalone: true,
      imports: [CommonModule, BaseChartDirective],
      templateUrl: './chart.component.html',
      styleUrl: './chart.component.css'
    })
    export class ChartComponent implements OnInit {
    
      public pieChartOptions: ChartConfiguration['options'] = {
        responsive: true,
        plugins: {
          legend: {
            display: true,
            position: 'top',
          },
        },
      };
    
      public pieChartData: ChartData<'pie', number[], string | string[]> = {
        labels: [],
        datasets: [{
          data: []
        }]
      };
      
      public pieChartType: ChartType = 'pie';
    
      constructor(private transactionService: TransactionService) {}
    
      ngOnInit(): void {
        this.updateChartData();
      }
    
      private updateChartData(): void {
        const spendingData = this.transactionService.getSpendingByCategory();
        this.pieChartData = {
          labels: spendingData.labels,
          datasets: [{
            data: spendingData.data
          }]
        };
      }
    }
    • TransactionServiceを注入し、取引データを取得します。
    • ng2-chartsライブラリのBaseChartDirectiveをインポートして使用しています。
    • pieChartOptions: グラフの表示設定(responsive: trueでレスポンシブ対応、凡例の位置など)を定義します。
    • pieChartData: グラフに表示するデータ(ラベルとデータセット)を保持するChartData型のプロパティです。
    • pieChartType: グラフの種類を'pie'(円グラフ)に設定します。
    • ngOnInit(): コンポーネントが初期化される際にupdateChartData()を呼び出し、グラフデータを準備します。
    • updateChartData()メソッド: TransactionService.getSpendingByCategory()メソッドを呼び出して、分類別の支出データを取得し、pieChartDataを更新します。これにより、グラフが最新のデータで再描画されます。
  • CSS (chart.component.css): グラフのコンテナの高さや幅など、基本的なレイアウトを定義しています。
  • .chart-container {
        position: relative;
        height: 400px; /* グラフのコンテナの高さ */
        width: 100%;
    }

ToastComponent: トースト通知

アプリケーション全体で利用できる、一時的なメッセージ通知(トースト)を表示するコンポーネントです。

  • 目的: ユーザーへのフィードバック(成功、エラー、情報)を非侵入的に、かつ魅力的なアニメーション付きで提供します。
  • HTML (toast.component.html):
    <div class="toast-container">
        @for (toast of toasts(); track toast) {
          <div class="toast" [ngClass]="'toast-' + toast.type" [@toastAnimation]>
            {{ toast.message }}
          </div>
        }
    </div>
    • toasts() Signalから取得した複数のトーストオブジェクトを@forループで反復表示します。
    • 各トーストはtoast-success, toast-error, toast-infoといった型に応じたCSSクラスと、@toastAnimationというAngularアニメーションが適用されます。
  • TypeScript (toast.component.ts):
    import { Component, inject } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { ToastService } from './toast.service';
    import { trigger, transition, style, animate } from '@angular/animations';
    
    @Component({
      selector: 'app-toast',
      standalone: true,
      imports: [CommonModule],
      templateUrl: './toast.component.html',
      styleUrl: './toast.component.css',
      animations: [
        trigger('toastAnimation', [
          transition(':enter', [
            style({ transform: 'translateY(100%)', opacity: 0 }),
            animate('300ms ease-out', style({ transform: 'translateY(0)', opacity: 1 })),
          ]),
          transition(':leave', [
            animate('300ms ease-in', style({ transform: 'translateY(100%)', opacity: 0 })),
          ]),
        ]),
      ]
    })
    export class ToastComponent {
      toastService = inject(ToastService);
      toasts = this.toastService.toasts;
    }
    • ToastServiceを注入し、サービスのtoasts Signalをコンポーネントのプロパティとして公開しています。これにより、サービスの状態が変更されると自動的にトーストが更新されます。
    • animationsプロパティでtoastAnimationトリガーを定義しています。これは、トーストが表示される際(:enter)に下からスライドアップしてフェードインし、消える際(:leave)に下へスライドダウンしてフェードアウトするスムーズなアニメーションを提供します。
  • CSS (toast.component.css): トーストが表示されるコンテナの位置(画面右下)、各トーストのパディング、角丸、影、背景色(成功、エラー、情報で異なる色)などを定義しています。
  • .toast-container {
        position: fixed;
        bottom: 20px;
        right: 20px;
        z-index: 1000;
        display: flex;
        flex-direction: column;
        gap: 10px;
    }
    
    .toast {
        padding: 15px 20px;
        border-radius: 5px;
        color: #fff;
        box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
        min-width: 250px;
        text-align: center;
    }
    
    .toast-success {
        background-color: #2ecc71;
    }
    
    .toast-error {
        background-color: #e74c3c;
    }
    
    .toast-info {
        background-color: #3498db;
    }

主要サービスの紹介

Angularサービスは、データ共有やビジネスロジックの集中管理に利用されます。本プロジェクトの主要なサービスとその機能について説明します。

TransactionService: 取引データ管理

家計簿の取引データを一元的に管理し、残高計算や分類別支出データの提供を行うサービスです。

  • 目的: コンポーネント間のデータ共有を容易にし、ビジネスロジックをカプセル化することで、アプリケーション全体のデータの一貫性を保ちます。
  • TypeScript (transaction.service.ts):
    import { Injectable, signal, computed } from '@angular/core';
    
    // 取引の型定義を拡張
    export type TransactionType = 'income' | 'expense';
    export type TransactionCategory = '食費' | '交通費' | '住居費' | '交際費' | '趣味' | 'その他' | '給与' | '臨時収入';
    
    export interface Transaction {
      id: number;
      date: string; // YYYY-MM-DD形式
      description: string;
      amount: number; // 金額(常に正の数)
      type: TransactionType; // 収支区分
      category: TransactionCategory;
    }
    
    @Injectable({
      providedIn: 'root'
    })
    export class TransactionService {
      private readonly initialBalance = 1_000_000;
    
      // カテゴリを支出用と収入用に分ける
      public readonly expenseCategories: TransactionCategory[] = ['食費', '交通費', '住居費', '交際費', '趣味', 'その他'];
      public readonly incomeCategories: TransactionCategory[] = ['給与', '臨時収入'];
    
      private transactionsState = signal<Transaction[]>([
        // サンプルデータを更新
        { id: 1, date: '2023-11-10', description: 'スーパーでの買い物', amount: 3500, type: 'expense', category: '食費' },
        { id: 2, date: '2023-11-11', description: '電車代', amount: 580, type: 'expense', category: '交通費' },
        { id: 3, date: '2023-11-12', description: '友人とのランチ', amount: 2000, type: 'expense', category: '交際費' },
        { id: 4, date: '2023-11-15', description: '家賃', amount: 85000, type: 'expense', category: '住居費' },
        { id: 5, date: '2023-11-18', description: '映画鑑賞', amount: 1900, type: 'expense', category: '趣味' },
        { id: 6, date: '2023-11-25', description: '給料', amount: 300000, type: 'income', category: '給与' },
      ]);
    
      public transactions = this.transactionsState.asReadonly();
    
      // 残高計算ロジックを修正
      public balance = computed(() => {
        const total = this.transactions().reduce((sum, transaction) => {
          if (transaction.type === 'income') {
            return sum + transaction.amount;
          } else {
            return sum - transaction.amount;
          }
        }, this.initialBalance);
        return total;
      });
    
      constructor() { }
    
      addTransaction(transactionData: Omit<Transaction, 'id'>) {
        this.transactionsState.update(currentTransactions => {
          const newTransaction: Transaction = {
            ...transactionData,
            id: Date.now(),
          };
          return [...currentTransactions, newTransaction].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
        });
      }
    
      // グラフは支出のみを対象とする
      getSpendingByCategory() {
        const spendingMap = new Map<string, number>();
        for (const category of this.expenseCategories) {
          spendingMap.set(category, 0);
        }
    
        for (const transaction of this.transactions()) {
          if (transaction.type === 'expense') {
            const currentTotal = spendingMap.get(transaction.category) || 0;
            spendingMap.set(transaction.category, currentTotal + transaction.amount);
          }
        }
        
        return {
          labels: Array.from(spendingMap.keys()),
          data: Array.from(spendingMap.values()),
        };
      }
    }
    • @Injectable({ providedIn: 'root' })として定義されており、アプリケーション全体で単一のインスタンスが利用可能です。
    • データ構造の定義:
      • TransactionType: 'income'(収入)または'expense'(支出)のどちらかを取る型。
      • TransactionCategory: 「食費」「給与」などの具体的なカテゴリのユニオン型。
      • Transactionインターフェース: 取引ID (id)、日付 (date: YYYY-MM-DD形式)、内容 (description)、金額 (amount: 常に正の数)、タイプ (type)、カテゴリ (category) を定義します。
    • プロパティ:
      • initialBalance: 家計簿の初期残高として1_000_000(100万円)が設定されています。
      • expenseCategories: 支出カテゴリの配列(例: 「食費」「交通費」)。
      • incomeCategories: 収入カテゴリの配列(例: 「給与」「臨時収入」)。
      • transactionsState: signal<Transaction[]>で、アプリケーション内の全ての取引データを保持します。初期サンプルデータが含まれており、起動時に利用されます。
      • transactions: transactionsStateの読み取り専用バージョンです。コンポーネントはこのSignalを購読して、取引データの変更を検知します。
      • balance: computed(() => { ... })で定義されたSignalで、現在のtransactions()データに基づいて残高を動的に計算します。初期残高から支出を減算し、収入を加算するロジックが含まれています。
    • 主要メソッド:
      • addTransaction(transactionData: Omit<Transaction, 'id'>): 新しい取引オブジェクト(IDを除く)を受け取り、Date.now()を使って一意のidを生成し、transactionsState Signalに追加します。追加後、取引リストは日付の新しい順にソートされます。
      • getSpendingByCategory(): 現在の取引データから、各支出カテゴリごとの合計金額を集計します。円グラフ表示のために、カテゴリ名(labels)と合計金額(data)の配列ペアを返します。このメソッドは'expense'タイプの取引のみを対象とし、収入は集計に含めません。

ToastService: トースト通知の管理

トーストメッセージの表示・非表示を管理するサービスです。

  • 目的: ToastComponentと連携し、アプリケーションの様々な場所から統一された方法で通知を表示できるようにします。これにより、コンポーネントは直接DOM操作を行うことなく、通知機能を利用できます。
  • TypeScript (toast.service.ts):
    import { Injectable, signal } from '@angular/core';
    
    export interface Toast {
      message: string;
      type: 'success' | 'error' | 'info';
      duration?: number;
    }
    
    @Injectable({
      providedIn: 'root'
    })
    export class ToastService {
      toasts = signal<Toast[]>([]);
    
      show(message: string, type: Toast['type'] = 'info', duration = 3000) {
        const newToast: Toast = { message, type, duration };
        this.toasts.update(currentToasts => [...currentToasts, newToast]);
    
        setTimeout(() => this.remove(newToast), duration);
      }
    
      remove(toastToRemove: Toast) {
        this.toasts.update(currentToasts => currentToasts.filter(toast => toast !== toastToRemove));
      }
    }
    • @Injectable({ providedIn: 'root' })として定義されており、アプリケーション全体で利用可能です。
    • Toastインターフェース: 表示されるトーストメッセージの構造(messagetype'success', 'error', 'info'のいずれか)、duration(表示期間))を定義します。
    • toasts: signal<Toast[]>で、現在表示されているすべてのトーストメッセージのリストを保持します。
    • 主要メソッド:
      • show(message: string, type: Toast['type'] = 'info', duration = 3000): 新しいトーストオブジェクトを作成し、toasts Signalに追加します。指定されたduration(デフォルトは3000ミリ秒)後に、setTimeoutを使ってそのトーストを自動的にremove()メソッドで削除します。
      • remove(toastToRemove: Toast): 指定されたトーストオブジェクトをtoasts Signalから削除します。これにより、該当するトーストが画面から消えます。

各画面の詳細

アプリケーションのナビゲーションと主要な画面について説明します。ルーティング設定はsrc/app/app.routes.tsで行われています。

  • トップページ / 残高・履歴画面 (/balance):
    • アプリケーション起動時のデフォルトルートです。
    • BalanceComponentが表示され、現在の残高と最新の取引履歴が一覧で確認できます。
    • ここから「取引を登録する」ボタンで取引登録画面へ遷移できます。
  • 取引登録画面 (/add):
    • TransactionFormComponentが表示され、新しい収入または支出の取引詳細を入力できます。
    • 入力後、「登録する」ボタンで取引が追加され、トースト通知が表示された後、自動的に残高・履歴画面に戻ります。
  • 分類グラフ画面 (/chart):
    • ChartComponentが表示され、支出がカテゴリ別にどれくらいの割合を占めているかを円グラフで視覚的に確認できます。
    • データがない場合は、その旨がユーザーに伝えられます。
  • デフォルトルートと不明なURLのリダイレクト:
    • path: ''は、ルートURL(例: http://localhost:4200/)にアクセスした際に/balanceにフルパスでリダイレクトされるデフォルトルートです。
    • path: '**'は、定義されていないURLにアクセスした場合に、/balanceにリダイレクトするワイルドカードルートです。

その他のプロジェクト設定とファイル

  • angular.json: Angular CLIのプロジェクト設定ファイルです。ビルド、開発サーバー(ng serve)、テスト(ng test)などの設定、アセットのパス(例: publicフォルダ内のfavicon.icoを含むすべてのファイルをビルド出力に含める)、スタイルシートやスクリプトの指定が記述されています。
  • package.json: プロジェクトのメタデータ、スクリプトコマンド(例: "start": "ng serve""build": "ng build""test": "ng test")、本番環境と開発環境の依存関係パッケージが定義されています。Angularのコアライブラリ、ルーター、アニメーション、リアクティブフォーム、Chart.jsやng2-chartsなどが依存関係に含まれています.
  • src/main.ts: アプリケーションのエントリーポイントです。bootstrapApplication(AppComponent, appConfig)を呼び出してAngularアプリケーションを起動します。また、Chart.jsのすべての必要なコンポーネント(Chart.register(...registerables))をグローバルに登録しています.
  • src/index.html: アプリケーションの単一ページアプリケーションの基盤となるHTMLファイルです。ここに<app-root></app-root>要素が配置され、Angularアプリケーションが初期化されます。
  • src/styles.css: アプリケーション全体のグローバルなスタイルを定義するCSSファイルです。ボディ、コンテナ、カード、タイポグラフィ、テーブル、ボタン、フォーム、ナビゲーションバーの一般的なスタイルが含まれています。
  • tsconfig.*.jsonファイル群 [24-26]: TypeScriptコンパイラのオプションを定義するファイルです。strict: trueなど、厳格な型チェック設定が含まれています。
  • .editorconfig: さまざまなエディタでコードのスタイル(インデント、改行など)を統一するための設定ファイルです。
  • .gitignore: Gitがバージョン管理で無視するファイルやディレクトリ(ビルド出力の/dist/node_modulesなど)を指定します。
  • .vscode/ディレクトリ: Visual Studio Codeのワークスペース設定が含まれます(推奨拡張機能、デバッグ設定、タスク定義など).

まとめ

このAngular製の家計簿アプリは、コンポーネント指向のアーキテクチャとサービスによるデータ管理を効果的に組み合わせたモダンなWebアプリケーションです。特にAngular Signalsを用いたリアクティブなデータフローと、Chart.jsによる視覚的なデータ表現が特徴であり、ユーザーは直感的かつ効率的に家計を管理できるよう設計されています。

コメント

このブログの人気の投稿

Power Automateでファイル名から拡張子を取得

PowerAppsで座席表を作成する

Power AutomateでTeamsのキーワードをトリガーにする