Angular実践 #1 ダッシュボードの骨格を作る

読了 9分

上級 #7 の最後で予告した実践シリーズです。基礎から上級まで道具はひと通り触ってきたので、いよいよ 1 つのプロダクトの文脈 にそれらの道具を集めて、小さな SaaS を最初から最後まで建ててみます。

このシリーズで作るのは 管理者ダッシュボード (Admin Dashboard) です。よくあるけれど扱う題材が豊富なドメインですね — 認証、CRUD、チャート、フォーム、テーブル、状態管理、デプロイまでが一カ所に集まります。6 編に分けます。

  • #1 ダッシュボードの骨格 ← 今回
  • #2 認証フロー (ログイン / トークン / ガード / インターセプター)
  • #3 Product CRUD (Reactive Forms + HttpClient + Resource API)
  • #4 Orders テーブルとチャート
  • #5 状態管理 (シグナル store → NgRx Signal Store)
  • #6 テストとデプロイ

今回は 骨格 です。画面を 1 ピクセルも描く前に、プロジェクトを作り、Angular Material を載せ、フォルダ構成を整え、メインレイアウトとルーティングを組み、最初の画面に placeholder の stat カード 4 つを表示するところまで進みます。

作るアプリの姿 #

典型的なアドミンの構造 — 上部に toolbar、左に sidebar、右にメイン領域。ページは次の 5 つです。

ルート画面シリーズでの登場
/dashboard統計カード + チャート#1 (placeholder) → #4 (チャート)
/products商品の一覧・作成・編集#3
/orders注文テーブル#4
/settingsユーザー / 環境設定#5
/loginログインページ#2

今回はルートと空のコンポーネントだけ敷いておき、肉付けは次回以降に進めます。

プロジェクトを作る #

基礎 #2 でやったように ng new で始めます。ただしオプションを先に指定して、質問画面はスキップします。

新しいプロジェクトを作る
ng new admin-dashboard --routing --style=scss --ssr=false --standalone
cd admin-dashboard

フラグの意味 — --routingapp.routes.ts を先に作り、--style=scss は変数・ネストが使える SCSS、--ssr=false は閉じたユーザー向けのアドミンなので SSR は不要だからオフ、--standalone は Angular 17+ のデフォルトですが明示しておきます。

Angular Material のセットアップ #

UI ライブラリは Angular Material を使います。アドミンはデザインそのものよりも「機能を素早く敷いて一貫させる」が優先なので、検証済みのコンポーネント群がよく合います。

Angular Material のインストール
ng add @angular/material

ng addnpm install とは別の段階 — schematic を一緒に走らせて、セットアップまで自動でやってくれます。受ける質問は 3 つ。

  1. Choose a prebuilt theme name, or “custom”:Azure/Blue のような無難な prebuilt テーマ。シリーズ後半で custom に切り替えます。
  2. Set up global Angular Material typography styles?Yes
  3. Include the Angular animations module?Include and enable animations

インストールが終わると、src/styles.scss にテーマの import が、app.config.tsprovideAnimations() が自動で入っています。

フォルダ構成 #

空のプロジェクトにフォルダから描いておきます。core / shared / features / layouts の 4 分割がアドミンによく合う標準的な構成ですね。

src/app/
src/app/
├── core/              ← シングルトン (auth, api, guards, interceptors)
├── shared/            ← stateless な再利用パーツ (StatCard, dialogs, pipes)
├── features/          ← ドメインごとのページ群
│   ├── dashboard/  products/  orders/  settings/  auth/
├── layouts/           ← MainLayout, AuthLayout
├── app.component.ts
├── app.config.ts
└── app.routes.ts

3 つのフォルダの違いを 1 行で:

  • core/ — 「1 カ所だけで作られるべき」もの。認証サービス、API クライアント、インターセプター
  • shared/ — どこからでも持ってきて使う stateless なパーツ。ボタン、カード、ダイアログ、パイプ
  • features/ — 実際の画面。1 つのドメインが 1 つのフォルダに、その中にコンポーネント・サービス・モデルが集まる
フォルダを作る
mkdir -p src/app/{core/{services,guards,interceptors},shared/components,features/{dashboard,products,orders,settings,auth},layouts/main-layout}
ヒント
フォルダを早すぎる段階で深く掘るのはアンチパターンですが、アドミンのように これからどんなコードが入ってくるか予測可能な ドメインでは、先に器を作っておくとコードを積むたびに「これはどこに置くべき?」と悩む必要がなくなります。構造のコストよりも、決定疲れの軽減のほうがはるかに大きいです。

メインレイアウトを作る #

いよいよ画面の大枠である MainLayoutComponent。サイドバー + ツールバー + メイン領域を一度組んでおけば、すべてのページがその枠の中で router-outlet を通じて差し替わります。

レイアウトコンポーネントの生成
ng g c layouts/main-layout --change-detection=OnPush --skip-tests

--change-detection=OnPush上級 #7 で強調した OnPush を最初から敷き、--skip-tests はひとまず spec ファイルをスキップします (テストは #6 でまとめて)。

src/app/layouts/main-layout/main-layout.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';

@Component({
  selector: 'app-main-layout',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [RouterOutlet, RouterLink, RouterLinkActive,
    MatToolbarModule, MatSidenavModule, MatListModule, MatIconModule, MatButtonModule],
  templateUrl: './main-layout.component.html',
  styleUrl: './main-layout.component.scss',
})
export class MainLayoutComponent {
  readonly menu = [
    { label: 'ダッシュボード', icon: 'dashboard',    link: '/dashboard' },
    { label: '商品',           icon: 'inventory_2',  link: '/products' },
    { label: '注文',           icon: 'receipt_long', link: '/orders' },
    { label: '設定',           icon: 'settings',     link: '/settings' },
  ];
  readonly opened = signal(true);
  toggle() { this.opened.update((v) => !v); }
}

imports 配列に登録したモジュールだけがテンプレートで使えます。テンプレートでは mat-sidenav-container で左右の領域を分け、左に mat-sidenav (サイドバー)、右に mat-sidenav-content (メイン領域) を置きます。

src/app/layouts/main-layout/main-layout.component.html
<mat-toolbar color="primary" class="topbar">
  <button mat-icon-button (click)="toggle()"><mat-icon>menu</mat-icon></button>
  <span class="title">Admin Dashboard</span>
  <span class="spacer"></span>
  <button mat-icon-button><mat-icon>notifications</mat-icon></button>
  <button mat-icon-button><mat-icon>account_circle</mat-icon></button>
</mat-toolbar>

<mat-sidenav-container class="layout">
  <mat-sidenav [opened]="opened()" mode="side" class="sidenav">
    <mat-nav-list>
      @for (item of menu; track item.link) {
        <a mat-list-item [routerLink]="item.link" routerLinkActive="active">
          <mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
          <span matListItemTitle>{{ item.label }}</span>
        </a>
      }
    </mat-nav-list>
  </mat-sidenav>
  <mat-sidenav-content class="content">
    <router-outlet />
  </mat-sidenav-content>
</mat-sidenav-container>

スタイルは大枠の領域だけ整えておきます。

src/app/layouts/main-layout/main-layout.component.scss
:host { display: flex; flex-direction: column; height: 100vh; }

.topbar { position: sticky; top: 0; z-index: 10; }
.topbar .title { font-weight: 500; margin-left: 8px; }
.topbar .spacer { flex: 1 1 auto; }

.layout { flex: 1; background: #f5f5f5; }
.sidenav { width: 240px; border-right: 1px solid rgba(0, 0, 0, 0.08); }
.content { padding: 24px; }

a.active { background: rgba(0, 0, 0, 0.04); font-weight: 600; }

@media (max-width: 768px) {
  .sidenav { width: 200px; }
  .content { padding: 16px; }
}

routerLinkActive="active" が肝 — 現在アクティブなルートのメニュー項目に自動で active クラスが付いて強調されます。別途「今どのページかな?」を追跡するコードなしで、ルーターが自動で処理してくれます。

ルーティングの骨格 #

次に app.routes.ts にページを並べておきます。ポイントは 2 つ — レイアウトを親ルートにする ことと、各 feature を lazy loading で分離する ことです。

src/app/app.routes.ts
import { Routes } from '@angular/router';
import { MainLayoutComponent } from './layouts/main-layout/main-layout.component';

export const routes: Routes = [
  {
    path: '',
    component: MainLayoutComponent,
    children: [
      { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
      { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent) },
      { path: 'products',  loadChildren: () => import('./features/products/products.routes').then(m => m.PRODUCTS_ROUTES) },
      { path: 'orders',    loadChildren: () => import('./features/orders/orders.routes').then(m => m.ORDERS_ROUTES) },
      { path: 'settings',  loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent) },
    ],
  },
  { path: 'login', loadComponent: () => import('./features/auth/login.component').then(m => m.LoginComponent) },
  { path: '**', redirectTo: 'dashboard' },
];

ここでは 2 つのパターンを併用しています。

  • loadComponent — コンポーネント 1 つを lazy で。単一ページに向く。
  • loadChildren — 子ルート群を lazy で。Products のように一覧・詳細・作成ページが 1 つの塊として動くドメインに向く。

products.routes.ts には子ルートだけを別途定義しておきます。

src/app/features/products/products.routes.ts
import { Routes } from '@angular/router';

export const PRODUCTS_ROUTES: Routes = [
  { path: '',    loadComponent: () => import('./product-list.component').then(m => m.ProductListComponent) },
  { path: 'new', loadComponent: () => import('./product-form.component').then(m => m.ProductFormComponent) },
  { path: ':id', loadComponent: () => import('./product-detail.component').then(m => m.ProductDetailComponent) },
];

各 feature コンポーネントは、今回は空の placeholder として作っておけば十分です — シリーズが進むにつれて 1 つずつ埋めていくところですから。

src/app/features/products/product-list.component.ts (placeholder)
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<h2>Products</h2><p>ここに商品一覧が入ります (#3)。</p>`,
})
export class ProductListComponent {}

ルートの AppComponent<router-outlet /> 1 行のシンプルな形にしておきます。

最初の画面 — Dashboard Home #

ダッシュボードのホームには placeholder データで stat カード 4 つを置いておきます。ここは #4 でチャートが入るところです。

まず stat カードを shared/components に切り出します — いろいろなところで再利用される stateless なパーツですから。

src/app/shared/components/stat-card/stat-card.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';

@Component({
  selector: 'app-stat-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatCardModule, MatIconModule],
  template: `
    <mat-card>
      <mat-card-content>
        <div class="row">
          <mat-icon>{{ icon() }}</mat-icon>
          <div>
            <div class="label">{{ label() }}</div>
            <div class="value">{{ value() }}</div>
          </div>
        </div>
        <div class="delta" [class.up]="delta() >= 0">
          {{ delta() >= 0 ? '▲' : '▼' }} {{ delta() }}%
        </div>
      </mat-card-content>
    </mat-card>
  `,
  styles: [`
    .row { display: flex; gap: 12px; align-items: center; }
    .label { font-size: 12px; color: rgba(0,0,0,0.6); }
    .value { font-size: 24px; font-weight: 600; }
    .delta { margin-top: 8px; font-size: 12px; color: #c62828; }
    .delta.up { color: #2e7d32; }
  `],
})
export class StatCardComponent {
  label = input.required<string>();
  value = input.required<string>();
  icon  = input.required<string>();
  delta = input.required<number>();
}

input.required<...>()上級 #2 で見たシグナル入力です。親が値を渡さなければコンパイルエラーで捕まえてくれます。

ではダッシュボードコンポーネントでカード 4 つを並べてみます。

src/app/features/dashboard/dashboard.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { StatCardComponent } from '../../shared/components/stat-card/stat-card.component';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [StatCardComponent],
  template: `
    <h2>ダッシュボード</h2>
    <div class="stats">
      <app-stat-card label="本日の売上" value="¥1,284,000" icon="payments"     [delta]="12" />
      <app-stat-card label="新規注文"   value="48"          icon="receipt_long" [delta]="-3" />
      <app-stat-card label="アクティブユーザー" value="312"  icon="group"        [delta]="5" />
      <app-stat-card label="在庫不足"   value="7"           icon="warning"      [delta]="-20" />
    </div>
    <section class="placeholder">
      <p>ここにチャートが入ります (#4)。</p>
    </section>
  `,
  styles: [`
    .stats {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 16px;
    }
    .placeholder {
      margin-top: 24px; padding: 48px; background: #fff;
      border-radius: 8px; text-align: center; color: rgba(0,0,0,0.6);
    }
  `],
})
export class DashboardComponent {}

grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)) がレスポンシブの肝 — 画面幅に合わせてカードが 4 列・3 列・2 列・1 列に自然に縮みます。メディアクエリなしで終わる、コスパの良いグリッドパターンですね。

サイドバートグルと動作確認 #

デスクトップではサイドバーは常に開きっぱなしで、モバイルではハンバーガーボタンでトグルします。上の MainLayoutComponentopened シグナルがその役割 — ボタンを押すと opened() が反転し、[opened]="opened()" バインディングで mat-sidenav が開閉します。

注記
mode="side" はサイドバーがコンテンツを横に押しのけ、mode="over" はコンテンツの上に被さって暗く敷かれます。モバイルのハンバーガーメニューでよく見るあの動作が over モードです。BreakpointObserver(['(max-width: 768px)']) をシグナルで受け取って mode を動的に切り替えるのが標準パターンですが、今回はシンプルなトグルまでにとどめて、#5 で一緒に手を入れます。

ng serve --open で立ち上げて、次を確認します。

  • 上部のツールバーにアイコンが、左のサイドバーにメニュー 4 つが表示されるか
  • ハンバーガーボタンを押すとサイドバーが開閉するか
  • メニューをクリックすると URL が変わり、右側の placeholder が差し替わるか
  • /dashboard で stat カード 4 つがグリッドで見えるか (ウィンドウ幅を狭めると縮むか)
  • ネットワークタブで /products に入ったときに別チャンクがダウンロードされるか (lazy loading の確認)

最後の項目が意外と重要なチェックポイントです — loadComponent/loadChildren が本当に分離されたチャンクとしてビルドされているか目で見ておくと、シリーズ後半でバンドルサイズを扱うときに直感が育っているはずです。

まとめ #

今回はアドミンダッシュボードの 骨格 を敷きました — ng new でプロジェクトのセットアップ、Angular Material の追加、core / shared / features / layouts のフォルダ構成、MainLayoutComponent (toolbar + sidenav + router-outlet)、loadComponentloadChildren で lazy ルーティング、そして stat カード 4 つの最初の画面まで。この骨格はシリーズを通してほとんど変わらない土台で、次回からこの上に肉が付き始めます。

次回 「Angular実践 #2 認証フロー」 では、/login ページを作り、JWT トークンの保存、ルートガードで認証されていないアクセスをブロック、HTTP インターセプターで自動的にトークンを添付し、有効期限切れ時の更新まで — 認証の全体フローを一気に作ります。上級 #4 で見た関数型ガードとインターセプターのパターンが、実践でどう組み合わさるかが見える回になります。

X