Skip to content

第4章: アーキテクチャ設計

本章では、財務会計システムのフロントエンドアーキテクチャについて詳しく解説します。Container / View パターンを中心に、状態管理戦略、型安全性の確保、財務会計特有の UI 設計上の考慮事項を説明します。

4.1 Container / View パターン

4.1.1 パターンの概要

Container / View パターン(Presentational and Container Components パターンとも呼ばれる)は、コンポーネントを「データ取得・状態管理」と「表示」の2つの責務に分離するアーキテクチャパターンです。

uml diagram

4.1.2 Container コンポーネントの責務

Container コンポーネントは以下の責務を担います。

責務 説明
データ取得 Orval 生成の React Query hooks を使用した API 呼び出し
状態管理 モーダル開閉、選択状態などのローカル状態管理
イベントハンドリング ユーザー操作に対する処理の実装
エラーハンドリング API エラーの処理と表示の制御
ローディング状態 読み込み中の状態管理と表示の制御

Container コンポーネントの例:

// src/components/master/account/AccountContainer.tsx
import React, { useState, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
  useGetAccounts,
  useCreateAccount,
  useUpdateAccount,
  useDeleteAccount,
  getGetAccountsQueryKey,
} from '@/api/generated/account/account';
import { AccountRequest, AccountResponse } from '@/api/model';
import { AccountCollection } from '@/views/master/account/AccountCollection';
import { AccountSingle } from '@/views/master/account/AccountSingle';
import { AccountEditModal } from '@/views/master/account/AccountEditModal';
import { Loading } from '@/views/common/Loading';
import { ErrorMessage } from '@/views/common/ErrorMessage';

type ModalMode = 'closed' | 'create' | 'edit';

export const AccountContainer: React.FC = () => {
  const queryClient = useQueryClient();

  // API 呼び出し(Orval 生成 hooks)
  const { data: accounts, isLoading, error, refetch } = useGetAccounts();
  const createMutation = useCreateAccount();
  const updateMutation = useUpdateAccount();
  const deleteMutation = useDeleteAccount();

  // ローカル状態
  const [modalMode, setModalMode] = useState<ModalMode>('closed');
  const [selectedAccount, setSelectedAccount] =
    useState<AccountResponse | null>(null);

  // イベントハンドラー
  const handleSelect = useCallback((account: AccountResponse) => {
    setSelectedAccount(account);
  }, []);

  const handleCreateClick = useCallback(() => {
    setSelectedAccount(null);
    setModalMode('create');
  }, []);

  const handleEditClick = useCallback(() => {
    setModalMode('edit');
  }, []);

  const handleModalClose = useCallback(() => {
    setModalMode('closed');
  }, []);

  const handleSave = useCallback(
    (data: AccountRequest) => {
      const onSuccess = () => {
        queryClient.invalidateQueries({
          queryKey: getGetAccountsQueryKey(),
        });
        setModalMode('closed');
      };

      if (modalMode === 'create') {
        createMutation.mutate({ data }, { onSuccess });
      } else if (modalMode === 'edit' && selectedAccount) {
        updateMutation.mutate(
          { accountCode: selectedAccount.accountCode, data },
          { onSuccess }
        );
      }
    },
    [modalMode, selectedAccount, createMutation, updateMutation, queryClient]
  );

  const handleDelete = useCallback(
    (accountCode: string) => {
      if (window.confirm('この勘定科目を削除しますか?')) {
        deleteMutation.mutate(
          { accountCode },
          {
            onSuccess: () => {
              queryClient.invalidateQueries({
                queryKey: getGetAccountsQueryKey(),
              });
              setSelectedAccount(null);
            },
          }
        );
      }
    },
    [deleteMutation, queryClient]
  );

  // ローディング状態
  if (isLoading) {
    return <Loading message="勘定科目を読み込み中..." />;
  }

  // エラー状態
  if (error) {
    return <ErrorMessage error={error} onRetry={() => refetch()} />;
  }

  // 正常時のレンダリング
  return (
    <div className="account-container">
      <div className="account-list">
        <AccountCollection
          accounts={accounts ?? []}
          selectedAccountCode={selectedAccount?.accountCode}
          onSelect={handleSelect}
          onCreate={handleCreateClick}
        />
      </div>

      {selectedAccount && (
        <div className="account-detail">
          <AccountSingle
            account={selectedAccount}
            onEdit={handleEditClick}
            onDelete={() => handleDelete(selectedAccount.accountCode)}
          />
        </div>
      )}

      {modalMode !== 'closed' && (
        <AccountEditModal
          account={modalMode === 'edit' ? selectedAccount : undefined}
          onSave={handleSave}
          onCancel={handleModalClose}
          isSubmitting={
            createMutation.isPending || updateMutation.isPending
          }
        />
      )}
    </div>
  );
};

4.1.3 View コンポーネントの責務

View コンポーネントは以下の特徴を持ちます。

特徴 説明
純粋な関数 Props のみに依存し、副作用を持たない
テスト容易性 Props を渡すだけでテスト可能
再利用性 異なる Container から利用可能
表示に専念 データの加工・整形のみ担当

View コンポーネントの例(一覧表示):

// src/views/master/account/AccountCollection.tsx
import React from 'react';
import { AccountResponse } from '@/api/model';
import './AccountCollection.css';

interface Props {
  accounts: AccountResponse[];
  selectedAccountCode?: string;
  onSelect: (account: AccountResponse) => void;
  onCreate: () => void;
}

export const AccountCollection: React.FC<Props> = ({
  accounts,
  selectedAccountCode,
  onSelect,
  onCreate,
}) => {
  // BS/PL でグループ化(表示ロジック)
  const bsAccounts = accounts.filter((a) => a.bsplType === 'B');
  const plAccounts = accounts.filter((a) => a.bsplType === 'P');

  return (
    <div className="account-collection">
      <div className="account-collection__header">
        <h2>勘定科目一覧</h2>
        <button
          className="account-collection__create-button"
          onClick={onCreate}
        >
          新規作成
        </button>
      </div>

      <div className="account-collection__tabs">
        <section className="account-collection__section">
          <h3>貸借対照表科目</h3>
          <AccountList
            accounts={bsAccounts}
            selectedAccountCode={selectedAccountCode}
            onSelect={onSelect}
          />
        </section>

        <section className="account-collection__section">
          <h3>損益計算書科目</h3>
          <AccountList
            accounts={plAccounts}
            selectedAccountCode={selectedAccountCode}
            onSelect={onSelect}
          />
        </section>
      </div>
    </div>
  );
};

// 内部コンポーネント
const AccountList: React.FC<{
  accounts: AccountResponse[];
  selectedAccountCode?: string;
  onSelect: (account: AccountResponse) => void;
}> = ({ accounts, selectedAccountCode, onSelect }) => (
  <ul className="account-list">
    {accounts.map((account) => (
      <li
        key={account.accountCode}
        className={`account-list__item ${
          account.accountCode === selectedAccountCode ? 'is-selected' : ''
        }`}
        onClick={() => onSelect(account)}
      >
        <span className="account-list__code">{account.accountCode}</span>
        <span className="account-list__name">{account.accountName}</span>
        <span className="account-list__type">{account.elementType}</span>
      </li>
    ))}
  </ul>
);

View コンポーネントの例(詳細表示):

// src/views/master/account/AccountSingle.tsx
import React from 'react';
import { AccountResponse } from '@/api/model';
import './AccountSingle.css';

interface Props {
  account: AccountResponse;
  onEdit: () => void;
  onDelete: () => void;
}

export const AccountSingle: React.FC<Props> = ({
  account,
  onEdit,
  onDelete,
}) => {
  return (
    <div className="account-single">
      <div className="account-single__header">
        <h2>{account.accountName}</h2>
        <div className="account-single__actions">
          <button onClick={onEdit}>編集</button>
          <button onClick={onDelete} className="is-danger">
            削除
          </button>
        </div>
      </div>

      <dl className="account-single__details">
        <div className="account-single__row">
          <dt>勘定科目コード</dt>
          <dd>{account.accountCode}</dd>
        </div>
        <div className="account-single__row">
          <dt>勘定科目名</dt>
          <dd>{account.accountName}</dd>
        </div>
        {account.accountAbbr && (
          <div className="account-single__row">
            <dt>略称</dt>
            <dd>{account.accountAbbr}</dd>
          </div>
        )}
        {account.accountKana && (
          <div className="account-single__row">
            <dt>カナ</dt>
            <dd>{account.accountKana}</dd>
          </div>
        )}
        <div className="account-single__row">
          <dt>BS/PL 区分</dt>
          <dd>
            {account.bsplType === 'B' ? '貸借対照表' : '損益計算書'}
          </dd>
        </div>
        <div className="account-single__row">
          <dt>貸借区分</dt>
          <dd>{account.debitCreditType}</dd>
        </div>
        <div className="account-single__row">
          <dt>要素区分</dt>
          <dd>{account.elementType}</dd>
        </div>
        <div className="account-single__row">
          <dt>表示順</dt>
          <dd>{account.displayOrder}</dd>
        </div>
      </dl>
    </div>
  );
};

View コンポーネントの例(編集モーダル):

// src/views/master/account/AccountEditModal.tsx
import React, { useState, useEffect } from 'react';
import Modal from 'react-modal';
import { AccountRequest, AccountResponse } from '@/api/model';
import './AccountEditModal.css';

interface Props {
  account?: AccountResponse;
  onSave: (data: AccountRequest) => void;
  onCancel: () => void;
  isSubmitting: boolean;
}

const initialFormData: AccountRequest = {
  accountCode: '',
  accountName: '',
  bsplType: 'B',
  debitCreditType: '借',
  elementType: '資産',
};

export const AccountEditModal: React.FC<Props> = ({
  account,
  onSave,
  onCancel,
  isSubmitting,
}) => {
  const isEditMode = !!account;
  const [formData, setFormData] = useState<AccountRequest>(initialFormData);

  // 編集モードの場合、初期値を設定
  useEffect(() => {
    if (account) {
      setFormData({
        accountCode: account.accountCode,
        accountName: account.accountName,
        accountAbbr: account.accountAbbr,
        accountKana: account.accountKana,
        bsplType: account.bsplType,
        debitCreditType: account.debitCreditType,
        elementType: account.elementType,
        displayOrder: account.displayOrder,
      });
    }
  }, [account]);

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSave(formData);
  };

  return (
    <Modal
      isOpen
      onRequestClose={onCancel}
      className="account-edit-modal"
      overlayClassName="modal-overlay"
    >
      <form onSubmit={handleSubmit}>
        <h2>{isEditMode ? '勘定科目編集' : '勘定科目登録'}</h2>

        <div className="form-group">
          <label htmlFor="accountCode">勘定科目コード</label>
          <input
            id="accountCode"
            name="accountCode"
            value={formData.accountCode}
            onChange={handleChange}
            disabled={isEditMode}
            required
          />
        </div>

        <div className="form-group">
          <label htmlFor="accountName">勘定科目名</label>
          <input
            id="accountName"
            name="accountName"
            value={formData.accountName}
            onChange={handleChange}
            required
          />
        </div>

        <div className="form-group">
          <label htmlFor="bsplType">BS/PL 区分</label>
          <select
            id="bsplType"
            name="bsplType"
            value={formData.bsplType}
            onChange={handleChange}
          >
            <option value="B">貸借対照表</option>
            <option value="P">損益計算書</option>
          </select>
        </div>

        <div className="form-group">
          <label htmlFor="debitCreditType">貸借区分</label>
          <select
            id="debitCreditType"
            name="debitCreditType"
            value={formData.debitCreditType}
            onChange={handleChange}
          >
            <option value="借">借方</option>
            <option value="貸">貸方</option>
          </select>
        </div>

        <div className="form-group">
          <label htmlFor="elementType">要素区分</label>
          <select
            id="elementType"
            name="elementType"
            value={formData.elementType}
            onChange={handleChange}
          >
            <option value="資産">資産</option>
            <option value="負債">負債</option>
            <option value="純資産">純資産</option>
            <option value="収益">収益</option>
            <option value="費用">費用</option>
          </select>
        </div>

        <div className="form-actions">
          <button type="button" onClick={onCancel} disabled={isSubmitting}>
            キャンセル
          </button>
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? '保存中...' : '保存'}
          </button>
        </div>
      </form>
    </Modal>
  );
};

4.1.4 ディレクトリ構成

Container と View を明確に分離したディレクトリ構成を示します。

src/
├── components/          # Container コンポーネント
│   ├── master/
│   │   ├── account/
│   │   │   └── AccountContainer.tsx
│   │   ├── accountingPeriod/
│   │   │   └── AccountingPeriodContainer.tsx
│   │   └── user/
│   │       └── UserContainer.tsx
│   ├── journal/
│   │   ├── entry/
│   │   │   └── JournalEntryContainer.tsx
│   │   └── list/
│   │       └── JournalListContainer.tsx
│   └── statement/
│       ├── balanceSheet/
│       │   └── BalanceSheetContainer.tsx
│       └── profitLoss/
│           └── ProfitLossContainer.tsx
│
└── views/               # View コンポーネント
    ├── master/
    │   ├── account/
    │   │   ├── AccountCollection.tsx
    │   │   ├── AccountSingle.tsx
    │   │   └── AccountEditModal.tsx
    │   └── ...
    ├── journal/
    │   ├── entry/
    │   │   ├── JournalEntryForm.tsx
    │   │   └── JournalDetailForm.tsx
    │   └── ...
    ├── statement/
    │   └── ...
    └── common/
        ├── Loading.tsx
        ├── ErrorMessage.tsx
        ├── MoneyDisplay.tsx
        └── DateDisplay.tsx

4.2 状態管理パターン

4.2.1 状態の分類

フロントエンドの状態を以下の3種類に分類し、それぞれ適切な管理方法を選択します。

uml diagram

状態の種類 管理方法
サーバー状態 TanStack Query(Orval 生成) 勘定科目一覧、仕訳データ、残高データ
クライアント状態 React Context 認証情報、会計期間選択、UI テーマ
ローカル状態 useState / useReducer フォーム入力、モーダル開閉、選択行

4.2.2 サーバー状態管理(TanStack Query)

サーバー状態は TanStack Query(React Query)で管理します。Orval で自動生成された hooks を使用します。

特徴: - 自動キャッシュ - バックグラウンド再取得 - 楽観的更新 - エラー・ローディング状態管理

queryClient の設定:

// src/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // キャッシュの鮮度(この時間内は再取得しない)
      staleTime: 5 * 60 * 1000, // 5分

      // キャッシュの保持時間
      gcTime: 30 * 60 * 1000, // 30分

      // エラー時のリトライ回数
      retry: 1,

      // ウィンドウフォーカス時の再取得を無効化
      refetchOnWindowFocus: false,

      // マウント時の再取得を無効化
      refetchOnMount: false,
    },
    mutations: {
      // ミューテーションのリトライは無効
      retry: 0,
    },
  },
});

マスタデータ用の設定オーバーライド:

// マスタデータは長めにキャッシュ
const { data: accounts } = useGetAccounts({
  staleTime: 10 * 60 * 1000, // 10分
});

// 頻繁に更新されるデータは短めに
const { data: journals } = useGetJournalEntries({
  staleTime: 1 * 60 * 1000, // 1分
});

4.2.3 クライアント状態管理(React Context)

アプリケーション全体で共有が必要な状態は React Context で管理します。

AuthProvider の実装:

// src/providers/AuthProvider.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
  roles: string[];
}

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (token: string, user: User) => void;
  logout: () => void;
  hasRole: (role: string) => boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // 初回マウント時にトークンを確認
  useEffect(() => {
    const token = localStorage.getItem('accessToken');
    const savedUser = localStorage.getItem('user');

    if (token && savedUser) {
      try {
        setUser(JSON.parse(savedUser));
      } catch {
        localStorage.removeItem('accessToken');
        localStorage.removeItem('user');
      }
    }
    setIsLoading(false);
  }, []);

  const login = (token: string, user: User) => {
    localStorage.setItem('accessToken', token);
    localStorage.setItem('user', JSON.stringify(user));
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('user');
    setUser(null);
  };

  const hasRole = (role: string) => {
    return user?.roles.includes(role) ?? false;
  };

  return (
    <AuthContext.Provider
      value={{
        user,
        isAuthenticated: !!user,
        isLoading,
        login,
        logout,
        hasRole,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

会計期間 Provider の実装:

// src/providers/AccountingPeriodProvider.tsx
import React, { createContext, useContext, useState } from 'react';

interface AccountingPeriod {
  startDate: string;
  endDate: string;
  year: number;
  month?: number;
}

interface AccountingPeriodContextType {
  currentPeriod: AccountingPeriod;
  setPeriod: (period: AccountingPeriod) => void;
  isMonthly: boolean;
  setIsMonthly: (monthly: boolean) => void;
}

const AccountingPeriodContext = createContext<
  AccountingPeriodContextType | undefined
>(undefined);

export const AccountingPeriodProvider: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const [currentPeriod, setCurrentPeriod] = useState<AccountingPeriod>(() => {
    const now = new Date();
    return {
      year: now.getFullYear(),
      startDate: `${now.getFullYear()}-04-01`,
      endDate: `${now.getFullYear() + 1}-03-31`,
    };
  });
  const [isMonthly, setIsMonthly] = useState(false);

  return (
    <AccountingPeriodContext.Provider
      value={{
        currentPeriod,
        setPeriod: setCurrentPeriod,
        isMonthly,
        setIsMonthly,
      }}
    >
      {children}
    </AccountingPeriodContext.Provider>
  );
};

export const useAccountingPeriod = () => {
  const context = useContext(AccountingPeriodContext);
  if (context === undefined) {
    throw new Error(
      'useAccountingPeriod must be used within AccountingPeriodProvider'
    );
  }
  return context;
};

4.2.4 Provider の構成

App.tsx での Provider 構成:

// src/App.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './queryClient';
import { AuthProvider } from './providers/AuthProvider';
import { AccountingPeriodProvider } from './providers/AccountingPeriodProvider';
import { RouteConfig } from './RouteConfig';

export const App: React.FC = () => (
  <QueryClientProvider client={queryClient}>
    <AuthProvider>
      <AccountingPeriodProvider>
        <RouteConfig />
      </AccountingPeriodProvider>
    </AuthProvider>
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

4.3 型安全性の確保

4.3.1 Orval による型定義の自動生成

Orval はバックエンドの DTO と完全に一致した TypeScript の型定義を生成します。これにより、フロントエンド・バックエンド間の型の不整合を防ぎます。

uml diagram

4.3.2 Union 型による厳密な型定義

OpenAPI の enum は TypeScript の Union 型として生成されます。

// 自動生成された型(src/api/model/accountResponse.ts)
export interface AccountResponse {
  accountCode: string;
  accountName: string;

  // enum は Union 型として生成
  bsplType: 'B' | 'P';
  debitCreditType: '借' | '貸';
  elementType: '資産' | '負債' | '純資産' | '収益' | '費用';

  displayOrder: number;
  version: number;
}

これにより、コンパイル時に不正な値を検出できます。

// コンパイルエラー:'X' は型 '"B" | "P"' に割り当てられません
const account: AccountResponse = {
  // ...
  bsplType: 'X', // エラー!
};

4.3.3 Props の型定義

View コンポーネントの Props は API の型を活用して定義します。

import { AccountResponse, AccountRequest } from '@/api/model';

// 一覧表示用 Props
interface AccountCollectionProps {
  accounts: AccountResponse[];
  selectedAccountCode?: string;
  onSelect: (account: AccountResponse) => void;
  onCreate: () => void;
}

// 詳細表示用 Props
interface AccountSingleProps {
  account: AccountResponse;
  onEdit: () => void;
  onDelete: () => void;
}

// 編集モーダル用 Props
interface AccountEditModalProps {
  account?: AccountResponse;  // undefined の場合は新規作成
  onSave: (data: AccountRequest) => void;
  onCancel: () => void;
  isSubmitting: boolean;
}

4.3.4 型ガード関数

実行時の型チェックが必要な場合は、型ガード関数を使用します。

// src/utils/typeGuards.ts
import { AccountResponse } from '@/api/model';

export const isAccountResponse = (obj: unknown): obj is AccountResponse => {
  if (typeof obj !== 'object' || obj === null) return false;

  const account = obj as Record<string, unknown>;
  return (
    typeof account.accountCode === 'string' &&
    typeof account.accountName === 'string' &&
    (account.bsplType === 'B' || account.bsplType === 'P') &&
    (account.debitCreditType === '借' || account.debitCreditType === '貸')
  );
};

// 使用例
const handleData = (data: unknown) => {
  if (isAccountResponse(data)) {
    // data は AccountResponse 型として扱われる
    console.log(data.accountName);
  }
};

4.4 財務会計特有の考慮事項

4.4.1 金額の取り扱い

金額計算には精度が重要です。JavaScript の浮動小数点演算ではなく、decimal.js を使用します。

金額ユーティリティ:

// src/utils/money.ts
import Decimal from 'decimal.js';

// 金額の型定義
export type Money = {
  amount: Decimal;
  currency: string;
};

// 金額を生成
export const createMoney = (
  amount: number | string,
  currency: string = 'JPY'
): Money => ({
  amount: new Decimal(amount),
  currency,
});

// 金額の加算
export const addMoney = (a: Money, b: Money): Money => {
  if (a.currency !== b.currency) {
    throw new Error('通貨が異なります');
  }
  return {
    amount: a.amount.plus(b.amount),
    currency: a.currency,
  };
};

// 金額の減算
export const subtractMoney = (a: Money, b: Money): Money => {
  if (a.currency !== b.currency) {
    throw new Error('通貨が異なります');
  }
  return {
    amount: a.amount.minus(b.amount),
    currency: a.currency,
  };
};

// 金額のフォーマット(3桁カンマ)
export const formatMoney = (money: Money): string => {
  const formatted = money.amount
    .toFixed(0)
    .replace(/\B(?=(\d{3})+(?!\d))/g, ',');

  if (money.currency === 'JPY') {
    return ${formatted}`;
  }
  return `${money.currency} ${formatted}`;
};

// 金額の比較
export const compareMoney = (a: Money, b: Money): number => {
  return a.amount.comparedTo(b.amount);
};

// ゼロ判定
export const isZero = (money: Money): boolean => {
  return money.amount.isZero();
};

金額表示コンポーネント:

// src/views/common/MoneyDisplay.tsx
import React from 'react';
import Decimal from 'decimal.js';
import './MoneyDisplay.css';

interface Props {
  amount: number | string;
  showSign?: boolean;
  colorBySign?: boolean;
  className?: string;
}

export const MoneyDisplay: React.FC<Props> = ({
  amount,
  showSign = false,
  colorBySign = false,
  className = '',
}) => {
  const decimal = new Decimal(amount);
  const isNegative = decimal.isNegative();
  const absValue = decimal.abs().toFixed(0);

  // 3桁カンマ区切り
  const formatted = absValue.replace(/\B(?=(\d{3})+(?!\d))/g, ',');

  // 符号の決定
  let sign = '';
  if (showSign) {
    sign = isNegative ? '-' : '+';
  } else if (isNegative) {
    sign = '-';
  }

  // CSSクラスの決定
  const colorClass = colorBySign
    ? isNegative
      ? 'is-negative'
      : 'is-positive'
    : '';

  return (
    <span className={`money-display ${colorClass} ${className}`}>
      {sign}¥{formatted}
    </span>
  );
};

4.4.2 貸借表示

複式簿記では、借方(デビット)と貸方(クレジット)を明確に区別して表示します。

仕訳明細表示コンポーネント:

// src/views/journal/JournalDetailView.tsx
import React from 'react';
import { JournalDetailResponse } from '@/api/model';
import { MoneyDisplay } from '@/views/common/MoneyDisplay';
import './JournalDetailView.css';

interface Props {
  details: JournalDetailResponse[];
}

export const JournalDetailView: React.FC<Props> = ({ details }) => {
  // 借方・貸方の合計を計算
  const totalDebit = details.reduce(
    (sum, d) => sum + (d.debitAmount ?? 0),
    0
  );
  const totalCredit = details.reduce(
    (sum, d) => sum + (d.creditAmount ?? 0),
    0
  );

  return (
    <table className="journal-detail-table">
      <thead>
        <tr>
          <th>勘定科目</th>
          <th className="amount-column">借方金額</th>
          <th className="amount-column">貸方金額</th>
          <th>摘要</th>
        </tr>
      </thead>
      <tbody>
        {details.map((detail, index) => (
          <tr key={index}>
            <td>{detail.accountName}</td>
            <td className="amount-column">
              {detail.debitAmount ? (
                <MoneyDisplay amount={detail.debitAmount} />
              ) : (
                ''
              )}
            </td>
            <td className="amount-column">
              {detail.creditAmount ? (
                <MoneyDisplay amount={detail.creditAmount} />
              ) : (
                ''
              )}
            </td>
            <td>{detail.description}</td>
          </tr>
        ))}
      </tbody>
      <tfoot>
        <tr className="total-row">
          <td>合計</td>
          <td className="amount-column">
            <MoneyDisplay amount={totalDebit} />
          </td>
          <td className="amount-column">
            <MoneyDisplay amount={totalCredit} />
          </td>
          <td></td>
        </tr>
      </tfoot>
    </table>
  );
};

4.4.3 貸借バランスの検証

仕訳入力時には、借方合計と貸方合計が一致することを検証します。

貸借バランス検証フック:

// src/hooks/useBalanceValidation.ts
import { useMemo } from 'react';
import Decimal from 'decimal.js';

interface JournalDetail {
  debitAmount?: number | string;
  creditAmount?: number | string;
}

interface BalanceValidationResult {
  totalDebit: Decimal;
  totalCredit: Decimal;
  difference: Decimal;
  isBalanced: boolean;
}

export const useBalanceValidation = (
  details: JournalDetail[]
): BalanceValidationResult => {
  return useMemo(() => {
    const totalDebit = details.reduce((sum, d) => {
      return sum.plus(new Decimal(d.debitAmount ?? 0));
    }, new Decimal(0));

    const totalCredit = details.reduce((sum, d) => {
      return sum.plus(new Decimal(d.creditAmount ?? 0));
    }, new Decimal(0));

    const difference = totalDebit.minus(totalCredit).abs();
    const isBalanced = difference.isZero();

    return {
      totalDebit,
      totalCredit,
      difference,
      isBalanced,
    };
  }, [details]);
};

使用例:

// src/components/journal/entry/JournalEntryContainer.tsx
import { useBalanceValidation } from '@/hooks/useBalanceValidation';

export const JournalEntryContainer: React.FC = () => {
  const [details, setDetails] = useState<JournalDetail[]>([]);
  const { isBalanced, totalDebit, totalCredit, difference } =
    useBalanceValidation(details);

  const handleSubmit = () => {
    if (!isBalanced) {
      alert(`貸借が一致しません。差額: ¥${difference.toFixed(0)}`);
      return;
    }
    // 保存処理
  };

  return (
    <JournalEntryForm
      details={details}
      onDetailsChange={setDetails}
      totalDebit={totalDebit.toNumber()}
      totalCredit={totalCredit.toNumber()}
      isBalanced={isBalanced}
      onSubmit={handleSubmit}
    />
  );
};

4.4.4 会計期間の管理

財務会計では、会計期間(決算期)の概念が重要です。

会計期間の型定義:

// src/types/accountingPeriod.ts
export interface AccountingPeriod {
  id: string;
  year: number;
  startDate: string;
  endDate: string;
  isClosed: boolean;
}

export interface FiscalMonth {
  year: number;
  month: number;
  startDate: string;
  endDate: string;
  isClosed: boolean;
}

会計期間セレクタコンポーネント:

// src/views/common/PeriodSelector.tsx
import React from 'react';
import { useAccountingPeriod } from '@/providers/AccountingPeriodProvider';
import './PeriodSelector.css';

export const PeriodSelector: React.FC = () => {
  const { currentPeriod, setPeriod, isMonthly, setIsMonthly } =
    useAccountingPeriod();

  return (
    <div className="period-selector">
      <div className="period-selector__type">
        <label>
          <input
            type="radio"
            checked={!isMonthly}
            onChange={() => setIsMonthly(false)}
          />
          年次
        </label>
        <label>
          <input
            type="radio"
            checked={isMonthly}
            onChange={() => setIsMonthly(true)}
          />
          月次
        </label>
      </div>

      <div className="period-selector__period">
        <span>
          {currentPeriod.year}年度
          {isMonthly && currentPeriod.month && ` ${currentPeriod.month}月`}
        </span>
        <span className="period-selector__range">
          ({currentPeriod.startDate}  {currentPeriod.endDate})
        </span>
      </div>
    </div>
  );
};

4.4.5 権限による表示制御

財務会計システムでは、ロールに基づいた表示制御が必要です。

権限チェックフック:

// src/hooks/usePermission.ts
import { useAuth } from '@/providers/AuthProvider';

type Permission =
  | 'account:read'
  | 'account:write'
  | 'journal:read'
  | 'journal:write'
  | 'journal:approve'
  | 'statement:read'
  | 'closing:execute'
  | 'admin:all';

const rolePermissions: Record<string, Permission[]> = {
  accountant: [
    'account:read',
    'account:write',
    'journal:read',
    'journal:write',
    'statement:read',
  ],
  manager: [
    'account:read',
    'journal:read',
    'journal:approve',
    'statement:read',
    'closing:execute',
  ],
  auditor: ['account:read', 'journal:read', 'statement:read'],
  admin: ['admin:all'],
};

export const usePermission = () => {
  const { user, hasRole } = useAuth();

  const hasPermission = (permission: Permission): boolean => {
    if (!user) return false;

    // 管理者は全権限を持つ
    if (hasRole('admin')) return true;

    // ユーザーの全ロールの権限をチェック
    return user.roles.some((role) => {
      const permissions = rolePermissions[role];
      return permissions?.includes(permission);
    });
  };

  return { hasPermission };
};

権限ガードコンポーネント:

// src/components/common/PermissionGuard.tsx
import React from 'react';
import { usePermission } from '@/hooks/usePermission';

interface Props {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export const PermissionGuard: React.FC<Props> = ({
  permission,
  children,
  fallback = null,
}) => {
  const { hasPermission } = usePermission();

  if (!hasPermission(permission)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
};

// 使用例
const JournalActions: React.FC = () => (
  <>
    <PermissionGuard permission="journal:write">
      <button>編集</button>
    </PermissionGuard>

    <PermissionGuard permission="journal:approve">
      <button>承認</button>
    </PermissionGuard>
  </>
);

4.5 コンポーネント設計指針

4.5.1 命名規則

種類 命名規則
Container 〇〇Container AccountContainer
一覧 View 〇〇Collection AccountCollection
詳細 View 〇〇Single AccountSingle
編集モーダル 〇〇EditModal AccountEditModal
検索モーダル 〇〇SearchModal JournalSearchModal
フォーム 〇〇Form JournalEntryForm

4.5.2 Props 設計の原則

1. コールバック Props の命名: - on〇〇: イベントハンドラー(例: onSelect, onCreate, onCancel) - is〇〇: boolean 状態(例: isLoading, isSubmitting, isSelected

2. データ Props と関数 Props の分離:

// 良い例: データと関数を明確に分離
interface AccountCollectionProps {
  // データ Props
  accounts: AccountResponse[];
  selectedAccountCode?: string;

  // 関数 Props
  onSelect: (account: AccountResponse) => void;
  onCreate: () => void;
}

3. 必須/オプショナルの明確化:

interface AccountEditModalProps {
  account?: AccountResponse;        // オプショナル(新規作成時は undefined)
  onSave: (data: AccountRequest) => void;  // 必須
  onCancel: () => void;             // 必須
  isSubmitting: boolean;            // 必須
  errors?: Record<string, string>;  // オプショナル
}

4.5.3 ファイル構成

各コンポーネントは以下のファイル構成とします。

src/views/master/account/
├── AccountCollection.tsx      # コンポーネント
├── AccountCollection.css      # スタイル
├── AccountCollection.test.tsx # テスト
└── index.ts                   # エクスポート

index.ts:

export { AccountCollection } from './AccountCollection';
export type { AccountCollectionProps } from './AccountCollection';

4.6 まとめ

本章では、財務会計システムのフロントエンドアーキテクチャについて解説しました。

重要ポイント

  1. Container / View パターン: データ取得と表示の責務を明確に分離
  2. 状態管理の使い分け: サーバー状態(TanStack Query)、クライアント状態(Context)、ローカル状態(useState)の適切な選択
  3. 型安全性: Orval 生成の型定義を活用し、コンパイル時に型エラーを検出
  4. 財務会計特有の考慮: 金額計算の精度、貸借バランス検証、権限制御

次章の内容

第5章では、アプリケーション基盤として、ルーティング設定、認証ガード、共通レイアウトの実装について解説します。