第11章: 仕訳入力¶
本章では、財務会計システムの中核機能である仕訳入力の実装を解説します。複式簿記の原則に基づく借方・貸方入力、勘定科目選択、消費税自動計算、貸借バランス検証など、仕訳入力に必要な機能を実装します。
11.1 仕訳入力画面¶
11.1.1 仕訳入力の概念¶
仕訳(Journal Entry)は、取引を借方と貸方に分けて記録する複式簿記の基本単位です。1つの仕訳は必ず借方合計と貸方合計が一致する必要があります。
11.1.2 型定義¶
src/types/journalEntry.ts:
// 仕訳ステータス
export type EntryStatus = 'draft' | 'pending' | 'approved' | 'rejected';
// 仕訳ヘッダー
export interface JournalEntry {
id: string;
slipNumber: string; // 伝票番号
entryDate: string; // 仕訳日付
description: string; // 摘要
status: EntryStatus; // ステータス
fiscalYear: number; // 会計年度
fiscalMonth: number; // 会計月
details: JournalDetail[]; // 仕訳明細
createdBy: string; // 作成者
createdAt: string; // 作成日時
updatedAt: string; // 更新日時
version: number; // 楽観的ロック用バージョン
}
// 仕訳明細
export interface JournalDetail {
id: string;
rowNumber: number; // 行番号
accountCode: string; // 勘定科目コード
accountName: string; // 勘定科目名
subAccountCode?: string; // 補助科目コード
subAccountName?: string; // 補助科目名
departmentCode?: string; // 部門コード
departmentName?: string; // 部門名
debitAmount: number; // 借方金額
creditAmount: number; // 貸方金額
taxType: string; // 課税区分
taxRate: number; // 税率
taxAmount: number; // 税額
description?: string; // 明細摘要
}
// 仕訳登録リクエスト
export interface JournalEntryRequest {
entryDate: string;
description: string;
details: JournalDetailRequest[];
}
// 仕訳明細登録リクエスト
export interface JournalDetailRequest {
rowNumber: number;
accountCode: string;
subAccountCode?: string;
departmentCode?: string;
debitAmount: number;
creditAmount: number;
taxType: string;
description?: string;
}
11.1.3 JournalEntryContainer¶
src/components/journal/entry/JournalEntryContainer.tsx:
import React, { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
useGetJournalEntry,
useCreateJournalEntry,
useUpdateJournalEntry,
getGetJournalEntriesQueryKey,
} from '@/api/generated/journal-entry/journal-entry';
import { JournalEntryRequest, JournalDetailRequest } from '@/api/model';
import { JournalEntryForm } from '@/views/journal/entry/JournalEntryForm';
import { Loading } from '@/views/common/Loading';
import { ErrorMessage } from '@/views/common/ErrorMessage';
import { useMessage } from '@/providers/MessageProvider';
import { useAccountingPeriod } from '@/providers/AccountingPeriodProvider';
export const JournalEntryContainer: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useMessage();
const { currentPeriod, isMonthClosed } = useAccountingPeriod();
const isEditMode = Boolean(id);
// 編集時は既存データを取得
const {
data: existingEntry,
isLoading,
error,
} = useGetJournalEntry(id!, {
query: {
enabled: isEditMode,
},
});
// 登録 mutation
const createMutation = useCreateJournalEntry();
// 更新 mutation
const updateMutation = useUpdateJournalEntry();
// 保存処理
const handleSave = useCallback(
async (data: JournalEntryRequest) => {
// 月次締め済みチェック
if (isMonthClosed(data.entryDate)) {
showError('指定された日付の月は締め処理済みのため、仕訳を登録できません。');
return;
}
try {
if (isEditMode && id) {
await updateMutation.mutateAsync({
id,
data: {
...data,
version: existingEntry!.version,
},
});
showSuccess('仕訳を更新しました。');
} else {
const result = await createMutation.mutateAsync({ data });
showSuccess('仕訳を登録しました。');
navigate(`/journal/${result.id}`);
}
// キャッシュを無効化
queryClient.invalidateQueries({
queryKey: getGetJournalEntriesQueryKey(),
});
} catch (err: any) {
if (err.response?.status === 409) {
showError('他のユーザーによって更新されています。再読み込みしてください。');
} else {
showError('保存に失敗しました。');
}
}
},
[isEditMode, id, existingEntry, isMonthClosed, createMutation, updateMutation, navigate, queryClient, showSuccess, showError]
);
// キャンセル処理
const handleCancel = useCallback(() => {
navigate('/journal');
}, [navigate]);
// 下書き保存処理
const handleSaveDraft = useCallback(
async (data: JournalEntryRequest) => {
try {
if (isEditMode && id) {
await updateMutation.mutateAsync({
id,
data: {
...data,
status: 'draft',
version: existingEntry!.version,
},
});
} else {
await createMutation.mutateAsync({
data: {
...data,
status: 'draft',
},
});
}
showSuccess('下書きを保存しました。');
} catch (err) {
showError('下書きの保存に失敗しました。');
}
},
[isEditMode, id, existingEntry, createMutation, updateMutation, showSuccess, showError]
);
if (isEditMode && isLoading) {
return <Loading />;
}
if (isEditMode && error) {
return <ErrorMessage error={error} />;
}
// 承認済み仕訳は編集不可
if (existingEntry?.status === 'approved') {
return (
<div className="error-container">
<p>承認済みの仕訳は編集できません。</p>
<button onClick={handleCancel}>一覧に戻る</button>
</div>
);
}
return (
<JournalEntryForm
entry={existingEntry}
defaultDate={currentPeriod?.startDate}
onSave={handleSave}
onSaveDraft={handleSaveDraft}
onCancel={handleCancel}
isSaving={createMutation.isPending || updateMutation.isPending}
/>
);
};
11.1.4 JournalEntryForm¶
src/views/journal/entry/JournalEntryForm.tsx:
import React, { useState, useCallback, useMemo } from 'react';
import { JournalEntryResponse, JournalEntryRequest, JournalDetailRequest } from '@/api/model';
import { JournalDetailForm } from './JournalDetailForm';
import { BalanceValidator } from './BalanceValidator';
import { JournalTemplateSelector } from './JournalTemplateSelector';
import { useJournalEntry } from '@/hooks/useJournalEntry';
import { useBalanceValidation } from '@/hooks/useBalanceValidation';
import dayjs from 'dayjs';
import './JournalEntryForm.css';
interface Props {
entry?: JournalEntryResponse;
defaultDate?: string;
onSave: (data: JournalEntryRequest) => Promise<void>;
onSaveDraft: (data: JournalEntryRequest) => Promise<void>;
onCancel: () => void;
isSaving: boolean;
}
export const JournalEntryForm: React.FC<Props> = ({
entry,
defaultDate,
onSave,
onSaveDraft,
onCancel,
isSaving,
}) => {
// 仕訳入力状態管理
const {
entryDate,
setEntryDate,
description,
setDescription,
details,
addDetail,
updateDetail,
removeDetail,
moveDetailUp,
moveDetailDown,
clearAll,
applyTemplate,
} = useJournalEntry(entry);
// 貸借バランス検証
const {
debitTotal,
creditTotal,
difference,
isBalanced,
validationErrors,
} = useBalanceValidation(details);
// テンプレート選択モーダル
const [showTemplateSelector, setShowTemplateSelector] = useState(false);
// フォームのバリデーション
const formErrors = useMemo(() => {
const errors: string[] = [];
if (!entryDate) {
errors.push('仕訳日付は必須です。');
}
if (!description.trim()) {
errors.push('摘要は必須です。');
}
if (details.length < 2) {
errors.push('仕訳明細は最低2行必要です。');
}
// 明細のバリデーション
details.forEach((detail, index) => {
if (!detail.accountCode) {
errors.push(`明細${index + 1}行目: 勘定科目を選択してください。`);
}
if (detail.debitAmount === 0 && detail.creditAmount === 0) {
errors.push(`明細${index + 1}行目: 借方または貸方の金額を入力してください。`);
}
if (detail.debitAmount > 0 && detail.creditAmount > 0) {
errors.push(`明細${index + 1}行目: 借方と貸方の両方に金額を入力することはできません。`);
}
});
return errors;
}, [entryDate, description, details]);
// 保存可否判定
const canSave = formErrors.length === 0 && isBalanced;
// 保存処理
const handleSave = useCallback(async () => {
if (!canSave) return;
const request: JournalEntryRequest = {
entryDate,
description,
details: details.map((detail, index) => ({
rowNumber: index + 1,
accountCode: detail.accountCode,
subAccountCode: detail.subAccountCode,
departmentCode: detail.departmentCode,
debitAmount: detail.debitAmount,
creditAmount: detail.creditAmount,
taxType: detail.taxType,
description: detail.description,
})),
};
await onSave(request);
}, [canSave, entryDate, description, details, onSave]);
// 下書き保存処理(貸借不一致でも保存可能)
const handleSaveDraft = useCallback(async () => {
if (formErrors.filter(e => !e.includes('貸借')).length > 0) return;
const request: JournalEntryRequest = {
entryDate,
description,
details: details.map((detail, index) => ({
rowNumber: index + 1,
accountCode: detail.accountCode,
subAccountCode: detail.subAccountCode,
departmentCode: detail.departmentCode,
debitAmount: detail.debitAmount,
creditAmount: detail.creditAmount,
taxType: detail.taxType,
description: detail.description,
})),
};
await onSaveDraft(request);
}, [formErrors, entryDate, description, details, onSaveDraft]);
// テンプレート選択
const handleSelectTemplate = useCallback(
(template: JournalDetailRequest[]) => {
applyTemplate(template);
setShowTemplateSelector(false);
},
[applyTemplate]
);
return (
<div className="journal-entry-form">
<div className="form-header">
<h2>{entry ? '仕訳編集' : '仕訳入力'}</h2>
<div className="header-actions">
<button
type="button"
className="btn-secondary"
onClick={() => setShowTemplateSelector(true)}
>
テンプレートから入力
</button>
<button
type="button"
className="btn-secondary"
onClick={clearAll}
>
クリア
</button>
</div>
</div>
{/* ヘッダー情報 */}
<div className="form-section">
<div className="form-row">
<div className="form-group">
<label htmlFor="entryDate">仕訳日付 *</label>
<input
type="date"
id="entryDate"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
required
/>
</div>
<div className="form-group slip-number">
<label>伝票番号</label>
<span className="readonly-value">
{entry?.slipNumber || '(自動採番)'}
</span>
</div>
</div>
<div className="form-row">
<div className="form-group description-group">
<label htmlFor="description">摘要 *</label>
<input
type="text"
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="取引の内容を入力"
maxLength={200}
required
/>
</div>
</div>
</div>
{/* 仕訳明細 */}
<div className="form-section details-section">
<div className="section-header">
<h3>仕訳明細</h3>
<button
type="button"
className="btn-add"
onClick={addDetail}
>
+ 行追加
</button>
</div>
<JournalDetailForm
details={details}
onUpdate={updateDetail}
onRemove={removeDetail}
onMoveUp={moveDetailUp}
onMoveDown={moveDetailDown}
/>
</div>
{/* 貸借バランス表示 */}
<BalanceValidator
debitTotal={debitTotal}
creditTotal={creditTotal}
difference={difference}
isBalanced={isBalanced}
errors={validationErrors}
/>
{/* バリデーションエラー表示 */}
{formErrors.length > 0 && (
<div className="validation-errors">
<ul>
{formErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
{/* アクションボタン */}
<div className="form-actions">
<button
type="button"
className="btn-secondary"
onClick={onCancel}
disabled={isSaving}
>
キャンセル
</button>
<button
type="button"
className="btn-secondary"
onClick={handleSaveDraft}
disabled={isSaving}
>
下書き保存
</button>
<button
type="button"
className="btn-primary"
onClick={handleSave}
disabled={!canSave || isSaving}
>
{isSaving ? '保存中...' : '保存'}
</button>
</div>
{/* テンプレート選択モーダル */}
{showTemplateSelector && (
<JournalTemplateSelector
onSelect={handleSelectTemplate}
onClose={() => setShowTemplateSelector(false)}
/>
)}
</div>
);
};
11.1.5 useJournalEntry フック¶
src/hooks/useJournalEntry.ts:
import { useState, useCallback, useMemo } from 'react';
import { JournalEntryResponse, JournalDetailRequest } from '@/api/model';
import dayjs from 'dayjs';
// 仕訳明細の内部表現
export interface JournalDetailState {
id: string; // 一意識別子(画面内)
accountCode: string;
accountName: string;
subAccountCode?: string;
subAccountName?: string;
departmentCode?: string;
departmentName?: string;
debitAmount: number;
creditAmount: number;
taxType: string;
description?: string;
}
// 初期明細の生成
const createEmptyDetail = (): JournalDetailState => ({
id: crypto.randomUUID(),
accountCode: '',
accountName: '',
debitAmount: 0,
creditAmount: 0,
taxType: 'non_taxable',
});
// 既存仕訳からの明細変換
const convertToDetailState = (
detail: JournalEntryResponse['details'][0]
): JournalDetailState => ({
id: detail.id || crypto.randomUUID(),
accountCode: detail.accountCode,
accountName: detail.accountName,
subAccountCode: detail.subAccountCode,
subAccountName: detail.subAccountName,
departmentCode: detail.departmentCode,
departmentName: detail.departmentName,
debitAmount: detail.debitAmount,
creditAmount: detail.creditAmount,
taxType: detail.taxType,
description: detail.description,
});
export const useJournalEntry = (existingEntry?: JournalEntryResponse) => {
// ヘッダー情報
const [entryDate, setEntryDate] = useState<string>(
existingEntry?.entryDate || dayjs().format('YYYY-MM-DD')
);
const [description, setDescription] = useState<string>(
existingEntry?.description || ''
);
// 明細情報
const [details, setDetails] = useState<JournalDetailState[]>(() => {
if (existingEntry?.details && existingEntry.details.length > 0) {
return existingEntry.details.map(convertToDetailState);
}
// 新規作成時は2行の空明細
return [createEmptyDetail(), createEmptyDetail()];
});
// 明細追加
const addDetail = useCallback(() => {
setDetails((prev) => [...prev, createEmptyDetail()]);
}, []);
// 明細更新
const updateDetail = useCallback(
(id: string, updates: Partial<JournalDetailState>) => {
setDetails((prev) =>
prev.map((detail) =>
detail.id === id ? { ...detail, ...updates } : detail
)
);
},
[]
);
// 明細削除
const removeDetail = useCallback((id: string) => {
setDetails((prev) => {
const filtered = prev.filter((detail) => detail.id !== id);
// 最低2行は維持
if (filtered.length < 2) {
return [...filtered, createEmptyDetail()];
}
return filtered;
});
}, []);
// 明細の行移動(上へ)
const moveDetailUp = useCallback((id: string) => {
setDetails((prev) => {
const index = prev.findIndex((d) => d.id === id);
if (index <= 0) return prev;
const newDetails = [...prev];
[newDetails[index - 1], newDetails[index]] = [
newDetails[index],
newDetails[index - 1],
];
return newDetails;
});
}, []);
// 明細の行移動(下へ)
const moveDetailDown = useCallback((id: string) => {
setDetails((prev) => {
const index = prev.findIndex((d) => d.id === id);
if (index < 0 || index >= prev.length - 1) return prev;
const newDetails = [...prev];
[newDetails[index], newDetails[index + 1]] = [
newDetails[index + 1],
newDetails[index],
];
return newDetails;
});
}, []);
// 全クリア
const clearAll = useCallback(() => {
setEntryDate(dayjs().format('YYYY-MM-DD'));
setDescription('');
setDetails([createEmptyDetail(), createEmptyDetail()]);
}, []);
// テンプレート適用
const applyTemplate = useCallback(
(templateDetails: JournalDetailRequest[]) => {
const newDetails = templateDetails.map((template) => ({
id: crypto.randomUUID(),
accountCode: template.accountCode,
accountName: '', // 別途取得が必要
subAccountCode: template.subAccountCode,
departmentCode: template.departmentCode,
debitAmount: template.debitAmount,
creditAmount: template.creditAmount,
taxType: template.taxType,
description: template.description,
}));
// 最低2行を確保
while (newDetails.length < 2) {
newDetails.push(createEmptyDetail());
}
setDetails(newDetails);
},
[]
);
// 借方にコピー(貸方明細を反転して借方明細に)
const copyToDebit = useCallback((sourceId: string) => {
const source = details.find((d) => d.id === sourceId);
if (!source || source.creditAmount === 0) return;
const newDetail: JournalDetailState = {
...createEmptyDetail(),
accountCode: source.accountCode,
accountName: source.accountName,
debitAmount: source.creditAmount,
creditAmount: 0,
taxType: source.taxType,
};
setDetails((prev) => [...prev, newDetail]);
}, [details]);
// 貸方にコピー(借方明細を反転して貸方明細に)
const copyToCredit = useCallback((sourceId: string) => {
const source = details.find((d) => d.id === sourceId);
if (!source || source.debitAmount === 0) return;
const newDetail: JournalDetailState = {
...createEmptyDetail(),
accountCode: source.accountCode,
accountName: source.accountName,
debitAmount: 0,
creditAmount: source.debitAmount,
taxType: source.taxType,
};
setDetails((prev) => [...prev, newDetail]);
}, [details]);
return {
// ヘッダー
entryDate,
setEntryDate,
description,
setDescription,
// 明細
details,
addDetail,
updateDetail,
removeDetail,
moveDetailUp,
moveDetailDown,
// ユーティリティ
clearAll,
applyTemplate,
copyToDebit,
copyToCredit,
};
};
11.2 仕訳明細入力¶
11.2.1 JournalDetailForm¶
src/views/journal/entry/JournalDetailForm.tsx:
import React from 'react';
import { JournalDetailState } from '@/hooks/useJournalEntry';
import { JournalDetailRow } from './JournalDetailRow';
import './JournalDetailForm.css';
interface Props {
details: JournalDetailState[];
onUpdate: (id: string, updates: Partial<JournalDetailState>) => void;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
}
export const JournalDetailForm: React.FC<Props> = ({
details,
onUpdate,
onRemove,
onMoveUp,
onMoveDown,
}) => {
return (
<div className="journal-detail-form">
<table className="detail-table">
<thead>
<tr>
<th className="col-row-number">行</th>
<th className="col-account">勘定科目</th>
<th className="col-sub-account">補助科目</th>
<th className="col-department">部門</th>
<th className="col-debit">借方金額</th>
<th className="col-credit">貸方金額</th>
<th className="col-tax-type">課税区分</th>
<th className="col-description">明細摘要</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{details.map((detail, index) => (
<JournalDetailRow
key={detail.id}
detail={detail}
rowNumber={index + 1}
isFirst={index === 0}
isLast={index === details.length - 1}
onUpdate={(updates) => onUpdate(detail.id, updates)}
onRemove={() => onRemove(detail.id)}
onMoveUp={() => onMoveUp(detail.id)}
onMoveDown={() => onMoveDown(detail.id)}
/>
))}
</tbody>
</table>
</div>
);
};
11.2.2 JournalDetailRow¶
src/views/journal/entry/JournalDetailRow.tsx:
import React, { useState, useCallback } from 'react';
import { JournalDetailState } from '@/hooks/useJournalEntry';
import { MoneyInput } from '@/views/common/MoneyInput';
import { AccountSelector } from '@/views/common/AccountSelector';
import { TaxTypeSelector } from '@/views/common/TaxTypeSelector';
import { FiChevronUp, FiChevronDown, FiTrash2 } from 'react-icons/fi';
import './JournalDetailRow.css';
interface Props {
detail: JournalDetailState;
rowNumber: number;
isFirst: boolean;
isLast: boolean;
onUpdate: (updates: Partial<JournalDetailState>) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
export const JournalDetailRow: React.FC<Props> = ({
detail,
rowNumber,
isFirst,
isLast,
onUpdate,
onRemove,
onMoveUp,
onMoveDown,
}) => {
const [showAccountSelector, setShowAccountSelector] = useState(false);
// 勘定科目選択
const handleAccountSelect = useCallback(
(account: { code: string; name: string }) => {
onUpdate({
accountCode: account.code,
accountName: account.name,
});
setShowAccountSelector(false);
},
[onUpdate]
);
// 借方金額変更
const handleDebitChange = useCallback(
(value: number) => {
onUpdate({
debitAmount: value,
// 借方に入力したら貸方はクリア
creditAmount: value > 0 ? 0 : detail.creditAmount,
});
},
[onUpdate, detail.creditAmount]
);
// 貸方金額変更
const handleCreditChange = useCallback(
(value: number) => {
onUpdate({
creditAmount: value,
// 貸方に入力したら借方はクリア
debitAmount: value > 0 ? 0 : detail.debitAmount,
});
},
[onUpdate, detail.debitAmount]
);
// 課税区分変更
const handleTaxTypeChange = useCallback(
(taxType: string) => {
onUpdate({ taxType });
},
[onUpdate]
);
// 明細摘要変更
const handleDescriptionChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onUpdate({ description: e.target.value });
},
[onUpdate]
);
// 行の状態判定
const hasDebit = detail.debitAmount > 0;
const hasCredit = detail.creditAmount > 0;
const hasAmount = hasDebit || hasCredit;
const rowClassName = `detail-row ${hasDebit ? 'debit-row' : ''} ${hasCredit ? 'credit-row' : ''}`;
return (
<tr className={rowClassName}>
<td className="col-row-number">{rowNumber}</td>
{/* 勘定科目 */}
<td className="col-account">
<div className="account-input">
<input
type="text"
value={detail.accountName || detail.accountCode}
onClick={() => setShowAccountSelector(true)}
readOnly
placeholder="選択..."
className={!detail.accountCode ? 'empty' : ''}
/>
{showAccountSelector && (
<AccountSelector
onSelect={handleAccountSelect}
onClose={() => setShowAccountSelector(false)}
/>
)}
</div>
</td>
{/* 補助科目 */}
<td className="col-sub-account">
<input
type="text"
value={detail.subAccountName || ''}
readOnly
placeholder="-"
disabled={!detail.accountCode}
/>
</td>
{/* 部門 */}
<td className="col-department">
<input
type="text"
value={detail.departmentName || ''}
readOnly
placeholder="-"
/>
</td>
{/* 借方金額 */}
<td className="col-debit">
<MoneyInput
value={detail.debitAmount}
onChange={handleDebitChange}
disabled={hasCredit}
/>
</td>
{/* 貸方金額 */}
<td className="col-credit">
<MoneyInput
value={detail.creditAmount}
onChange={handleCreditChange}
disabled={hasDebit}
/>
</td>
{/* 課税区分 */}
<td className="col-tax-type">
<TaxTypeSelector
value={detail.taxType}
onChange={handleTaxTypeChange}
disabled={!hasAmount}
/>
</td>
{/* 明細摘要 */}
<td className="col-description">
<input
type="text"
value={detail.description || ''}
onChange={handleDescriptionChange}
placeholder="明細の備考"
maxLength={100}
/>
</td>
{/* 操作ボタン */}
<td className="col-actions">
<div className="action-buttons">
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
title="上へ移動"
className="btn-icon"
>
<FiChevronUp />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
title="下へ移動"
className="btn-icon"
>
<FiChevronDown />
</button>
<button
type="button"
onClick={onRemove}
title="削除"
className="btn-icon btn-delete"
>
<FiTrash2 />
</button>
</div>
</td>
</tr>
);
};
11.2.3 TaxTypeSelector¶
src/views/common/TaxTypeSelector.tsx:
import React from 'react';
import { useGetTaxTypes } from '@/api/generated/tax-type/tax-type';
import './TaxTypeSelector.css';
interface Props {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export const TaxTypeSelector: React.FC<Props> = ({
value,
onChange,
disabled = false,
}) => {
const { data: taxTypes, isLoading } = useGetTaxTypes();
if (isLoading) {
return <select disabled><option>読込中...</option></select>;
}
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="tax-type-selector"
>
<option value="non_taxable">対象外</option>
{taxTypes?.map((taxType) => (
<option key={taxType.code} value={taxType.code}>
{taxType.name}({taxType.rate}%)
</option>
))}
</select>
);
};
11.3 貸借バランス検証¶
11.3.1 useBalanceValidation フック¶
src/hooks/useBalanceValidation.ts:
import { useMemo } from 'react';
import Decimal from 'decimal.js';
import { JournalDetailState } from './useJournalEntry';
interface BalanceValidationResult {
debitTotal: number;
creditTotal: number;
difference: number;
isBalanced: boolean;
validationErrors: string[];
}
export const useBalanceValidation = (
details: JournalDetailState[]
): BalanceValidationResult => {
return useMemo(() => {
const errors: string[] = [];
// 借方合計・貸方合計の計算(decimal.js で精度保証)
let debitTotal = new Decimal(0);
let creditTotal = new Decimal(0);
details.forEach((detail, index) => {
if (detail.debitAmount > 0 && detail.creditAmount > 0) {
errors.push(
`${index + 1}行目: 借方と貸方の両方に金額が入力されています。`
);
}
debitTotal = debitTotal.plus(new Decimal(detail.debitAmount || 0));
creditTotal = creditTotal.plus(new Decimal(detail.creditAmount || 0));
});
// 差額計算
const difference = debitTotal.minus(creditTotal);
const isBalanced = difference.isZero();
if (!isBalanced) {
const diffValue = difference.toNumber();
if (diffValue > 0) {
errors.push(
`貸借が一致していません。借方が ${Math.abs(diffValue).toLocaleString()} 円多くなっています。`
);
} else {
errors.push(
`貸借が一致していません。貸方が ${Math.abs(diffValue).toLocaleString()} 円多くなっています。`
);
}
}
// 合計が0の場合のチェック
if (debitTotal.isZero() && creditTotal.isZero()) {
errors.push('借方・貸方のいずれかに金額を入力してください。');
}
return {
debitTotal: debitTotal.toNumber(),
creditTotal: creditTotal.toNumber(),
difference: difference.toNumber(),
isBalanced,
validationErrors: errors,
};
}, [details]);
};
11.3.2 BalanceValidator¶
src/views/journal/entry/BalanceValidator.tsx:
import React from 'react';
import { FiCheckCircle, FiAlertCircle } from 'react-icons/fi';
import './BalanceValidator.css';
interface Props {
debitTotal: number;
creditTotal: number;
difference: number;
isBalanced: boolean;
errors: string[];
}
export const BalanceValidator: React.FC<Props> = ({
debitTotal,
creditTotal,
difference,
isBalanced,
errors,
}) => {
const formatMoney = (amount: number) => amount.toLocaleString();
return (
<div className={`balance-validator ${isBalanced ? 'balanced' : 'unbalanced'}`}>
<div className="balance-summary">
<div className="balance-row">
<span className="label">借方合計:</span>
<span className="amount debit">{formatMoney(debitTotal)} 円</span>
</div>
<div className="balance-row">
<span className="label">貸方合計:</span>
<span className="amount credit">{formatMoney(creditTotal)} 円</span>
</div>
<div className="balance-divider" />
<div className="balance-row difference">
<span className="label">差額:</span>
<span className={`amount ${difference === 0 ? 'zero' : 'non-zero'}`}>
{difference >= 0 ? '+' : ''}{formatMoney(difference)} 円
</span>
</div>
</div>
<div className="balance-status">
{isBalanced ? (
<div className="status-indicator success">
<FiCheckCircle />
<span>貸借一致</span>
</div>
) : (
<div className="status-indicator error">
<FiAlertCircle />
<span>貸借不一致</span>
</div>
)}
</div>
{errors.length > 0 && (
<div className="balance-errors">
<ul>
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
</div>
);
};
11.3.3 スタイル定義¶
src/views/journal/entry/BalanceValidator.css:
.balance-validator {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
border-radius: 4px;
background-color: #f8f9fa;
margin-top: 1rem;
}
.balance-validator.balanced {
border-left: 4px solid #28a745;
}
.balance-validator.unbalanced {
border-left: 4px solid #dc3545;
background-color: #fff8f8;
}
.balance-summary {
flex: 1;
min-width: 200px;
}
.balance-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
}
.balance-row .label {
color: #666;
}
.balance-row .amount {
font-weight: bold;
font-family: 'Consolas', monospace;
}
.balance-row .amount.debit {
color: #0066cc;
}
.balance-row .amount.credit {
color: #cc0066;
}
.balance-row.difference .amount.zero {
color: #28a745;
}
.balance-row.difference .amount.non-zero {
color: #dc3545;
}
.balance-divider {
border-top: 1px solid #ddd;
margin: 0.5rem 0;
}
.balance-status {
display: flex;
align-items: center;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: bold;
}
.status-indicator.success {
background-color: #d4edda;
color: #155724;
}
.status-indicator.error {
background-color: #f8d7da;
color: #721c24;
}
.balance-errors {
width: 100%;
margin-top: 0.5rem;
}
.balance-errors ul {
margin: 0;
padding-left: 1.5rem;
color: #dc3545;
}
.balance-errors li {
margin: 0.25rem 0;
}
11.4 仕訳テンプレート¶
11.4.1 テンプレートの概念¶
仕訳テンプレートは、よく使用する仕訳パターンを保存し、再利用するための機能です。
11.4.2 JournalTemplateContainer¶
src/components/journal/template/JournalTemplateContainer.tsx:
import React, { useState, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
useGetJournalTemplates,
useCreateJournalTemplate,
useUpdateJournalTemplate,
useDeleteJournalTemplate,
getGetJournalTemplatesQueryKey,
} from '@/api/generated/journal-template/journal-template';
import {
JournalTemplateResponse,
JournalTemplateRequest,
} from '@/api/model';
import { JournalTemplateCollection } from '@/views/journal/template/JournalTemplateCollection';
import { JournalTemplateEditModal } from '@/views/journal/template/JournalTemplateEditModal';
import { Loading } from '@/views/common/Loading';
import { ErrorMessage } from '@/views/common/ErrorMessage';
import { useConfirm } from '@/hooks/useConfirm';
import { ConfirmModal } from '@/views/common/ConfirmModal';
import { useMessage } from '@/providers/MessageProvider';
export const JournalTemplateContainer: React.FC = () => {
const queryClient = useQueryClient();
const { showSuccess, showError } = useMessage();
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirm();
// テンプレート一覧取得
const { data: templates, isLoading, error } = useGetJournalTemplates();
// 各種 mutation
const createMutation = useCreateJournalTemplate();
const updateMutation = useUpdateJournalTemplate();
const deleteMutation = useDeleteJournalTemplate();
// 編集モーダル
const [editingTemplate, setEditingTemplate] = useState<JournalTemplateResponse | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
// 新規作成
const handleCreate = useCallback(async (data: JournalTemplateRequest) => {
try {
await createMutation.mutateAsync({ data });
showSuccess('テンプレートを作成しました。');
setShowCreateModal(false);
queryClient.invalidateQueries({
queryKey: getGetJournalTemplatesQueryKey(),
});
} catch (err) {
showError('テンプレートの作成に失敗しました。');
}
}, [createMutation, queryClient, showSuccess, showError]);
// 更新
const handleUpdate = useCallback(async (data: JournalTemplateRequest) => {
if (!editingTemplate) return;
try {
await updateMutation.mutateAsync({
id: editingTemplate.id,
data,
});
showSuccess('テンプレートを更新しました。');
setEditingTemplate(null);
queryClient.invalidateQueries({
queryKey: getGetJournalTemplatesQueryKey(),
});
} catch (err) {
showError('テンプレートの更新に失敗しました。');
}
}, [editingTemplate, updateMutation, queryClient, showSuccess, showError]);
// 削除
const handleDelete = useCallback(async (template: JournalTemplateResponse) => {
const confirmed = await confirm(
`テンプレート「${template.templateName}」を削除しますか?`
);
if (!confirmed) return;
try {
await deleteMutation.mutateAsync({ id: template.id });
showSuccess('テンプレートを削除しました。');
queryClient.invalidateQueries({
queryKey: getGetJournalTemplatesQueryKey(),
});
} catch (err) {
showError('テンプレートの削除に失敗しました。');
}
}, [confirm, deleteMutation, queryClient, showSuccess, showError]);
if (isLoading) return <Loading />;
if (error) return <ErrorMessage error={error} />;
return (
<div className="journal-template-container">
<JournalTemplateCollection
templates={templates || []}
onEdit={setEditingTemplate}
onDelete={handleDelete}
onCreate={() => setShowCreateModal(true)}
/>
{/* 新規作成モーダル */}
{showCreateModal && (
<JournalTemplateEditModal
onSave={handleCreate}
onClose={() => setShowCreateModal(false)}
isSaving={createMutation.isPending}
/>
)}
{/* 編集モーダル */}
{editingTemplate && (
<JournalTemplateEditModal
template={editingTemplate}
onSave={handleUpdate}
onClose={() => setEditingTemplate(null)}
isSaving={updateMutation.isPending}
/>
)}
{/* 確認モーダル */}
<ConfirmModal
isOpen={confirmState.isOpen}
message={confirmState.message}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div>
);
};
11.4.3 JournalTemplateSelector¶
src/views/journal/entry/JournalTemplateSelector.tsx:
import React, { useState, useCallback } from 'react';
import Modal from 'react-modal';
import { useGetJournalTemplates } from '@/api/generated/journal-template/journal-template';
import { JournalDetailRequest } from '@/api/model';
import { Loading } from '@/views/common/Loading';
import { FiSearch, FiX } from 'react-icons/fi';
import './JournalTemplateSelector.css';
interface Props {
onSelect: (details: JournalDetailRequest[]) => void;
onClose: () => void;
}
export const JournalTemplateSelector: React.FC<Props> = ({
onSelect,
onClose,
}) => {
const { data: templates, isLoading } = useGetJournalTemplates();
const [searchKeyword, setSearchKeyword] = useState('');
// 検索フィルタ
const filteredTemplates = templates?.filter((template) =>
template.templateName.toLowerCase().includes(searchKeyword.toLowerCase()) ||
template.description?.toLowerCase().includes(searchKeyword.toLowerCase())
);
// テンプレート選択
const handleSelect = useCallback(
(templateId: string) => {
const template = templates?.find((t) => t.id === templateId);
if (template) {
onSelect(template.details);
}
},
[templates, onSelect]
);
return (
<Modal
isOpen={true}
onRequestClose={onClose}
className="template-selector-modal"
overlayClassName="modal-overlay"
>
<div className="modal-header">
<h3>仕訳テンプレート選択</h3>
<button className="btn-close" onClick={onClose}>
<FiX />
</button>
</div>
<div className="modal-body">
{/* 検索バー */}
<div className="search-bar">
<FiSearch />
<input
type="text"
placeholder="テンプレート名で検索..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
</div>
{isLoading ? (
<Loading />
) : (
<div className="template-list">
{filteredTemplates?.length === 0 ? (
<div className="empty-message">
テンプレートがありません
</div>
) : (
filteredTemplates?.map((template) => (
<div
key={template.id}
className="template-item"
onClick={() => handleSelect(template.id)}
>
<div className="template-info">
<h4>{template.templateName}</h4>
<p>{template.description}</p>
<div className="template-details">
{template.details.slice(0, 3).map((detail, index) => (
<span key={index} className="detail-preview">
{detail.accountCode}
{index < Math.min(template.details.length, 3) - 1 && ' / '}
</span>
))}
{template.details.length > 3 && (
<span className="more">+{template.details.length - 3}件</span>
)}
</div>
</div>
<div className="template-badge">
{template.isShared ? '共有' : '個人'}
</div>
</div>
))
)}
</div>
)}
</div>
</Modal>
);
};
11.4.4 テンプレートから仕訳入力への流れ¶
11.5 消費税自動計算¶
11.5.1 useTaxCalculation フック¶
src/hooks/useTaxCalculation.ts:
import { useCallback } from 'react';
import Decimal from 'decimal.js';
import { useGetTaxCodes } from '@/api/generated/tax-code/tax-code';
interface TaxCalculationResult {
taxIncludedAmount: number; // 税込金額
taxExcludedAmount: number; // 税抜金額
taxAmount: number; // 税額
}
export const useTaxCalculation = () => {
const { data: taxCodes } = useGetTaxCodes();
// 税率の取得
const getTaxRate = useCallback(
(taxType: string): number => {
if (taxType === 'non_taxable') return 0;
const taxCode = taxCodes?.find((code) => code.code === taxType);
return taxCode?.rate ?? 0;
},
[taxCodes]
);
// 税込金額から計算(内税)
const calculateFromTaxIncluded = useCallback(
(taxIncludedAmount: number, taxType: string): TaxCalculationResult => {
const rate = getTaxRate(taxType);
if (rate === 0) {
return {
taxIncludedAmount,
taxExcludedAmount: taxIncludedAmount,
taxAmount: 0,
};
}
const taxIncluded = new Decimal(taxIncludedAmount);
const taxRate = new Decimal(rate).dividedBy(100);
// 税抜金額 = 税込金額 / (1 + 税率)
const taxExcluded = taxIncluded.dividedBy(
new Decimal(1).plus(taxRate)
).toDecimalPlaces(0, Decimal.ROUND_DOWN);
// 税額 = 税込金額 - 税抜金額
const tax = taxIncluded.minus(taxExcluded);
return {
taxIncludedAmount,
taxExcludedAmount: taxExcluded.toNumber(),
taxAmount: tax.toNumber(),
};
},
[getTaxRate]
);
// 税抜金額から計算(外税)
const calculateFromTaxExcluded = useCallback(
(taxExcludedAmount: number, taxType: string): TaxCalculationResult => {
const rate = getTaxRate(taxType);
if (rate === 0) {
return {
taxIncludedAmount: taxExcludedAmount,
taxExcludedAmount,
taxAmount: 0,
};
}
const taxExcluded = new Decimal(taxExcludedAmount);
const taxRate = new Decimal(rate).dividedBy(100);
// 税額 = 税抜金額 × 税率(端数切り捨て)
const tax = taxExcluded
.times(taxRate)
.toDecimalPlaces(0, Decimal.ROUND_DOWN);
// 税込金額 = 税抜金額 + 税額
const taxIncluded = taxExcluded.plus(tax);
return {
taxIncludedAmount: taxIncluded.toNumber(),
taxExcludedAmount,
taxAmount: tax.toNumber(),
};
},
[getTaxRate]
);
return {
getTaxRate,
calculateFromTaxIncluded,
calculateFromTaxExcluded,
};
};
11.5.2 税額計算の統合¶
仕訳明細入力時に課税区分を選択すると、自動的に税額が計算されます。
JournalDetailRow の拡張:
// JournalDetailRow.tsx に追加
import { useTaxCalculation } from '@/hooks/useTaxCalculation';
// コンポーネント内
const { calculateFromTaxIncluded } = useTaxCalculation();
// 課税区分変更時に税額を自動計算
const handleTaxTypeChange = useCallback(
(taxType: string) => {
const amount = detail.debitAmount || detail.creditAmount;
if (amount > 0 && taxType !== 'non_taxable') {
const { taxAmount } = calculateFromTaxIncluded(amount, taxType);
onUpdate({
taxType,
taxAmount,
});
} else {
onUpdate({
taxType,
taxAmount: 0,
});
}
},
[detail.debitAmount, detail.creditAmount, calculateFromTaxIncluded, onUpdate]
);
11.6 スタイル定義¶
11.6.1 仕訳入力フォームのスタイル¶
src/views/journal/entry/JournalEntryForm.css:
.journal-entry-form {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.form-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.form-section {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-group label {
font-size: 0.875rem;
color: #666;
font-weight: 500;
}
.form-group input[type="date"],
.form-group input[type="text"] {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1);
}
.description-group {
flex: 1;
}
.description-group input {
width: 100%;
}
.slip-number .readonly-value {
padding: 0.5rem;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
color: #666;
}
.details-section .section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.details-section h3 {
margin: 0;
font-size: 1.125rem;
}
.btn-add {
padding: 0.5rem 1rem;
background: #28a745;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-add:hover {
background: #218838;
}
.validation-errors {
background: #fff8f8;
border: 1px solid #dc3545;
border-radius: 4px;
padding: 1rem;
margin-top: 1rem;
}
.validation-errors ul {
margin: 0;
padding-left: 1.5rem;
color: #dc3545;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
}
.btn-primary,
.btn-secondary {
padding: 0.75rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.btn-primary {
background: #0066cc;
color: #fff;
border: none;
}
.btn-primary:hover:not(:disabled) {
background: #0052a3;
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-secondary {
background: #fff;
color: #666;
border: 1px solid #ddd;
}
.btn-secondary:hover:not(:disabled) {
background: #f5f5f5;
}
11.6.2 明細テーブルのスタイル¶
src/views/journal/entry/JournalDetailForm.css:
.journal-detail-form {
overflow-x: auto;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.detail-table th,
.detail-table td {
padding: 0.5rem;
border: 1px solid #e0e0e0;
text-align: left;
}
.detail-table th {
background: #f5f5f5;
font-weight: 500;
color: #333;
white-space: nowrap;
}
/* カラム幅の定義 */
.col-row-number {
width: 40px;
text-align: center;
}
.col-account {
width: 180px;
}
.col-sub-account {
width: 120px;
}
.col-department {
width: 100px;
}
.col-debit,
.col-credit {
width: 130px;
}
.col-tax-type {
width: 120px;
}
.col-description {
width: auto;
min-width: 150px;
}
.col-actions {
width: 100px;
text-align: center;
}
/* 行のスタイル */
.detail-row.debit-row {
background-color: #f0f7ff;
}
.detail-row.credit-row {
background-color: #fff0f5;
}
/* 入力フィールド */
.detail-table input,
.detail-table select {
width: 100%;
padding: 0.375rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.875rem;
}
.detail-table input:focus,
.detail-table select:focus {
outline: none;
border-color: #0066cc;
}
.detail-table input.empty {
color: #999;
}
.detail-table input:disabled,
.detail-table select:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
/* アクションボタン */
.action-buttons {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.btn-icon {
padding: 0.25rem;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
color: #666;
}
.btn-icon:hover:not(:disabled) {
background: #f0f0f0;
border-color: #ddd;
}
.btn-icon:disabled {
color: #ccc;
cursor: not-allowed;
}
.btn-icon.btn-delete:hover:not(:disabled) {
color: #dc3545;
background: #fff5f5;
}
11.7 まとめ¶
本章では、仕訳入力機能の実装について解説しました。
主要コンポーネント¶
- JournalEntryContainer: 仕訳入力画面のコンテナ
- JournalEntryForm: 仕訳入力フォーム全体
- JournalDetailForm / JournalDetailRow: 明細入力テーブル
- BalanceValidator: 貸借バランス検証・表示
- JournalTemplateSelector: テンプレート選択モーダル
カスタムフック¶
- useJournalEntry: 仕訳入力状態の管理
- useBalanceValidation: 貸借バランス検証
- useTaxCalculation: 消費税計算
設計のポイント¶
- 複式簿記の原則: 借方・貸方の排他入力、貸借一致の検証
- decimal.js: 金額計算の精度保証
- テンプレート機能: よく使う仕訳パターンの再利用
- バリデーション: リアルタイムでのエラー表示
- 月次締めチェック: 締め済み月への仕訳登録防止