第19章 キャッシュフロー計算書¶
本章では、キャッシュフロー計算書(Cash Flow Statement、C/F)の表示機能を実装する。営業・投資・財務活動ごとのキャッシュフロー分析、間接法による表示、ウォーターフォールチャートによる可視化など、資金繰りの把握に必要なコンポーネントを構築していく。
19.1 キャッシュフロー計算書の構造¶
3つの活動区分¶
キャッシュフロー計算書は、企業の現金の流れを以下の3つの活動に区分して表示する:
Ⅰ. 営業活動によるキャッシュフロー
税引前当期純利益
+ 減価償却費
+ 引当金の増減
+ 売上債権の増減
+ 棚卸資産の増減
+ 仕入債務の増減
+ その他
─ 法人税等の支払額
────────────────
営業活動によるキャッシュフロー
Ⅱ. 投資活動によるキャッシュフロー
+ 有形固定資産の取得/売却
+ 無形固定資産の取得/売却
+ 投資有価証券の取得/売却
+ 貸付金の増減
────────────────
投資活動によるキャッシュフロー
Ⅲ. 財務活動によるキャッシュフロー
+ 借入金の増減
+ 社債の発行/償還
+ 株式の発行
─ 配当金の支払
────────────────
財務活動によるキャッシュフロー
────────────────
現金及び現金同等物の増減額
+ 現金及び現金同等物の期首残高
────────────────
現金及び現金同等物の期末残高
型定義¶
// types/cashFlow.ts
/** キャッシュフロー活動区分 */
export type CFActivity =
| 'operating' // 営業活動
| 'investing' // 投資活動
| 'financing'; // 財務活動
/** キャッシュフロー項目種別 */
export type CFItemType =
| 'profitBase' // 税引前当期純利益(基準)
| 'depreciation' // 減価償却費
| 'provision' // 引当金増減
| 'receivable' // 売上債権増減
| 'inventory' // 棚卸資産増減
| 'payable' // 仕入債務増減
| 'interest' // 利息及び配当金
| 'tax' // 法人税等
| 'fixedAsset' // 固定資産増減
| 'investment' // 投資増減
| 'loan' // 貸付金増減
| 'borrowing' // 借入金増減
| 'bond' // 社債増減
| 'equity' // 株式増減
| 'dividend' // 配当金
| 'other'; // その他
/** キャッシュフロー項目 */
export interface CFItem {
id: string;
code: string;
name: string;
activity: CFActivity;
itemType: CFItemType;
amount: number;
previousAmount?: number;
children?: CFItem[];
level: number;
displayOrder: number;
description?: string;
}
/** 活動別キャッシュフロー */
export interface ActivityCashFlow {
activity: CFActivity;
label: string;
items: CFItem[];
subtotal: number;
previousSubtotal?: number;
}
/** キャッシュフローサマリー */
export interface CFSummary {
operatingCF: number;
investingCF: number;
financingCF: number;
netChange: number;
beginningBalance: number;
endingBalance: number;
freeCashFlow: number; // 営業CF + 投資CF
}
/** キャッシュフロー計算書データ */
export interface CashFlowData {
periodStart: string;
periodEnd: string;
activities: ActivityCashFlow[];
summary: CFSummary;
previousSummary?: CFSummary;
}
/** キャッシュフロー検索条件 */
export interface CFSearchParams {
periodStart: string;
periodEnd: string;
compareWithPrevious: boolean;
method: 'indirect' | 'direct';
}
19.2 API 連携¶
OpenAPI 定義¶
# openapi/paths/cash-flow.yaml
/api/cash-flow:
get:
operationId: getCashFlow
summary: キャッシュフロー計算書取得
tags:
- CashFlow
parameters:
- name: periodStart
in: query
required: true
schema:
type: string
format: date
- name: periodEnd
in: query
required: true
schema:
type: string
format: date
- name: compareWithPrevious
in: query
schema:
type: boolean
default: false
- name: method
in: query
schema:
type: string
enum: [indirect, direct]
default: indirect
responses:
'200':
description: キャッシュフロー計算書
content:
application/json:
schema:
$ref: '#/components/schemas/CashFlowResponse'
/api/cash-flow/monthly-trend:
get:
operationId: getCFMonthlyTrend
summary: キャッシュフロー月次推移取得
tags:
- CashFlow
parameters:
- name: fiscalYear
in: query
required: true
schema:
type: integer
responses:
'200':
description: 月次推移データ
content:
application/json:
schema:
$ref: '#/components/schemas/CFMonthlyTrendResponse'
/api/cash-flow/forecast:
get:
operationId: getCashFlowForecast
summary: 資金繰り予測取得
tags:
- CashFlow
parameters:
- name: baseDate
in: query
required: true
schema:
type: string
format: date
- name: months
in: query
schema:
type: integer
default: 3
responses:
'200':
description: 資金繰り予測
content:
application/json:
schema:
$ref: '#/components/schemas/CashFlowForecastResponse'
Orval 生成フック¶
// generated/api/cash-flow.ts
import { useQuery } from '@tanstack/react-query';
import type {
CashFlowResponse,
CFMonthlyTrendResponse,
CashFlowForecastResponse,
GetCashFlowParams,
GetCFMonthlyTrendParams,
GetCashFlowForecastParams,
} from '../model';
import { apiClient } from '../client';
export const getCashFlowQueryKey = (params: GetCashFlowParams) =>
['cash-flow', params] as const;
export const useGetCashFlow = (
params: GetCashFlowParams,
options?: { enabled?: boolean }
) => {
return useQuery({
queryKey: getCashFlowQueryKey(params),
queryFn: async () => {
const response = await apiClient.get<CashFlowResponse>(
'/api/cash-flow',
{ params }
);
return response.data;
},
...options,
});
};
export const getCFMonthlyTrendQueryKey = (params: GetCFMonthlyTrendParams) =>
['cash-flow', 'monthly-trend', params] as const;
export const useGetCFMonthlyTrend = (
params: GetCFMonthlyTrendParams,
options?: { enabled?: boolean }
) => {
return useQuery({
queryKey: getCFMonthlyTrendQueryKey(params),
queryFn: async () => {
const response = await apiClient.get<CFMonthlyTrendResponse>(
'/api/cash-flow/monthly-trend',
{ params }
);
return response.data;
},
...options,
});
};
export const getCashFlowForecastQueryKey = (params: GetCashFlowForecastParams) =>
['cash-flow', 'forecast', params] as const;
export const useGetCashFlowForecast = (
params: GetCashFlowForecastParams,
options?: { enabled?: boolean }
) => {
return useQuery({
queryKey: getCashFlowForecastQueryKey(params),
queryFn: async () => {
const response = await apiClient.get<CashFlowForecastResponse>(
'/api/cash-flow/forecast',
{ params }
);
return response.data;
},
...options,
});
};
19.3 Container 実装¶
CashFlowContainer¶
// containers/CashFlowContainer.tsx
import { useState, useCallback, useMemo } from 'react';
import { useGetCashFlow } from '../generated/api/cash-flow';
import { useAccountingPeriod } from '../contexts/AccountingPeriodContext';
import { useMessage } from '../contexts/MessageContext';
import { useCFExport } from '../hooks/useCFExport';
import { CashFlowView } from '../views/CashFlowView';
import type { CFSearchParams } from '../types/cashFlow';
import { formatDate, getFiscalYearRange } from '../utils/dateUtils';
export const CashFlowContainer: React.FC = () => {
const { currentPeriod } = useAccountingPeriod();
const { showMessage } = useMessage();
const { exportToPdf, exportToExcel, isExporting } = useCFExport();
// 検索条件(デフォルトは今期)
const [searchParams, setSearchParams] = useState<CFSearchParams>(() => {
if (currentPeriod) {
return {
periodStart: currentPeriod.startDate,
periodEnd: currentPeriod.endDate,
compareWithPrevious: false,
method: 'indirect' as const,
};
}
const { start, end } = getFiscalYearRange(new Date());
return {
periodStart: formatDate(start),
periodEnd: formatDate(end),
compareWithPrevious: false,
method: 'indirect' as const,
};
});
// 表示設定
const [expandedActivities, setExpandedActivities] = useState<Set<string>>(
new Set(['operating', 'investing', 'financing'])
);
// データ取得
const {
data: cfData,
isLoading,
error,
refetch,
} = useGetCashFlow(searchParams);
// 検索実行
const handleSearch = useCallback((params: CFSearchParams) => {
setSearchParams(params);
}, []);
// 活動区分の展開/折りたたみ
const handleToggleActivity = useCallback((activity: string) => {
setExpandedActivities(prev => {
const next = new Set(prev);
if (next.has(activity)) {
next.delete(activity);
} else {
next.add(activity);
}
return next;
});
}, []);
// 全展開/全折りたたみ
const handleExpandAll = useCallback(() => {
setExpandedActivities(new Set(['operating', 'investing', 'financing']));
}, []);
const handleCollapseAll = useCallback(() => {
setExpandedActivities(new Set());
}, []);
// PDF 出力
const handleExportPdf = useCallback(async () => {
if (!cfData) return;
try {
await exportToPdf(cfData, searchParams);
showMessage('success', 'PDF を出力しました');
} catch (e) {
showMessage('error', 'PDF 出力に失敗しました');
}
}, [cfData, searchParams, exportToPdf, showMessage]);
// Excel 出力
const handleExportExcel = useCallback(async () => {
if (!cfData) return;
try {
await exportToExcel(cfData, searchParams);
showMessage('success', 'Excel を出力しました');
} catch (e) {
showMessage('error', 'Excel 出力に失敗しました');
}
}, [cfData, searchParams, exportToExcel, showMessage]);
if (error) {
return (
<div className="error-container">
<p>データの取得に失敗しました</p>
<button onClick={() => refetch()}>再試行</button>
</div>
);
}
return (
<CashFlowView
data={cfData}
searchParams={searchParams}
isLoading={isLoading}
isExporting={isExporting}
expandedActivities={expandedActivities}
onSearch={handleSearch}
onToggleActivity={handleToggleActivity}
onExpandAll={handleExpandAll}
onCollapseAll={handleCollapseAll}
onExportPdf={handleExportPdf}
onExportExcel={handleExportExcel}
/>
);
};
19.4 View 実装¶
CashFlowView¶
// views/CashFlowView.tsx
import { CFSearchForm } from '../components/CFSearchForm';
import { CFSummaryPanel } from '../components/CFSummaryPanel';
import { CFStatement } from '../components/CFStatement';
import { CFWaterfallChart } from '../components/CFWaterfallChart';
import { LoadingSpinner } from '../components/common/LoadingSpinner';
import type { CashFlowData, CFSearchParams } from '../types/cashFlow';
interface CashFlowViewProps {
data?: CashFlowData;
searchParams: CFSearchParams;
isLoading: boolean;
isExporting: boolean;
expandedActivities: Set<string>;
onSearch: (params: CFSearchParams) => void;
onToggleActivity: (activity: string) => void;
onExpandAll: () => void;
onCollapseAll: () => void;
onExportPdf: () => void;
onExportExcel: () => void;
}
export const CashFlowView: React.FC<CashFlowViewProps> = ({
data,
searchParams,
isLoading,
isExporting,
expandedActivities,
onSearch,
onToggleActivity,
onExpandAll,
onCollapseAll,
onExportPdf,
onExportExcel,
}) => {
return (
<div className="cash-flow-view">
<header className="page-header">
<h1>キャッシュフロー計算書</h1>
<div className="header-actions">
<button
onClick={onExportPdf}
disabled={!data || isExporting}
className="btn btn-secondary"
>
PDF 出力
</button>
<button
onClick={onExportExcel}
disabled={!data || isExporting}
className="btn btn-secondary"
>
Excel 出力
</button>
</div>
</header>
<CFSearchForm
initialValues={searchParams}
onSearch={onSearch}
isLoading={isLoading}
/>
{isLoading ? (
<LoadingSpinner />
) : data ? (
<div className="cf-content">
{/* サマリーパネル */}
<CFSummaryPanel
summary={data.summary}
previousSummary={data.previousSummary}
compareWithPrevious={searchParams.compareWithPrevious}
/>
{/* ウォーターフォールチャート */}
<CFWaterfallChart
summary={data.summary}
title="キャッシュフロー構成"
/>
{/* 表示コントロール */}
<div className="display-controls">
<div className="control-group">
<button onClick={onExpandAll} className="btn btn-text">
すべて展開
</button>
<button onClick={onCollapseAll} className="btn btn-text">
すべて折りたたむ
</button>
</div>
<div className="method-indicator">
表示方式: {searchParams.method === 'indirect' ? '間接法' : '直接法'}
</div>
</div>
{/* キャッシュフロー計算書本体 */}
<CFStatement
activities={data.activities}
summary={data.summary}
compareWithPrevious={searchParams.compareWithPrevious}
expandedActivities={expandedActivities}
onToggleActivity={onToggleActivity}
/>
</div>
) : (
<div className="empty-state">
<p>検索条件を指定して表示してください</p>
</div>
)}
</div>
);
};
19.5 サマリーパネル¶
CFSummaryPanel コンポーネント¶
// components/CFSummaryPanel.tsx
import Decimal from 'decimal.js';
import { formatCurrency } from '../utils/formatUtils';
import type { CFSummary } from '../types/cashFlow';
interface CFSummaryPanelProps {
summary: CFSummary;
previousSummary?: CFSummary;
compareWithPrevious: boolean;
}
export const CFSummaryPanel: React.FC<CFSummaryPanelProps> = ({
summary,
previousSummary,
compareWithPrevious,
}) => {
// 増減を計算
const calculateChange = (current: number, previous?: number): number | null => {
if (previous === undefined) return null;
return new Decimal(current).minus(previous).toNumber();
};
const items = [
{
key: 'operating',
label: '営業活動CF',
amount: summary.operatingCF,
previousAmount: previousSummary?.operatingCF,
description: '本業による現金創出力',
isPositiveGood: true,
},
{
key: 'investing',
label: '投資活動CF',
amount: summary.investingCF,
previousAmount: previousSummary?.investingCF,
description: '設備投資・資産運用',
isPositiveGood: false, // 成長企業はマイナスが多い
},
{
key: 'financing',
label: '財務活動CF',
amount: summary.financingCF,
previousAmount: previousSummary?.financingCF,
description: '資金調達・返済',
isPositiveGood: null, // 状況による
},
{
key: 'free',
label: 'フリーCF',
amount: summary.freeCashFlow,
previousAmount: previousSummary?.freeCashFlow,
description: '自由に使える現金',
isPositiveGood: true,
},
];
return (
<div className="cf-summary-panel">
<h2>キャッシュフロー概要</h2>
{/* 活動別サマリー */}
<div className="cf-summary-cards">
{items.map(item => (
<div
key={item.key}
className={`cf-summary-card ${getCFClass(item.amount, item.isPositiveGood)}`}
>
<div className="card-header">
<span className="card-label">{item.label}</span>
<span className="card-description">{item.description}</span>
</div>
<div className="card-amount">
{item.amount >= 0 ? '+' : ''}
{formatCurrency(item.amount)}
</div>
{compareWithPrevious && (
<div className="card-change">
<ChangeDisplay
change={calculateChange(item.amount, item.previousAmount)}
isPositiveGood={item.isPositiveGood}
/>
</div>
)}
</div>
))}
</div>
{/* 現金残高 */}
<div className="cf-balance-summary">
<div className="balance-flow">
<div className="balance-item beginning">
<span className="balance-label">期首残高</span>
<span className="balance-amount">
{formatCurrency(summary.beginningBalance)}
</span>
</div>
<div className="balance-arrow">→</div>
<div className="balance-item change">
<span className="balance-label">増減額</span>
<span className={`balance-amount ${summary.netChange >= 0 ? 'positive' : 'negative'}`}>
{summary.netChange >= 0 ? '+' : ''}
{formatCurrency(summary.netChange)}
</span>
</div>
<div className="balance-arrow">→</div>
<div className="balance-item ending">
<span className="balance-label">期末残高</span>
<span className="balance-amount">
{formatCurrency(summary.endingBalance)}
</span>
</div>
</div>
</div>
</div>
);
};
// CFの評価クラスを取得
const getCFClass = (amount: number, isPositiveGood: boolean | null): string => {
if (isPositiveGood === null) return '';
if (isPositiveGood) {
return amount >= 0 ? 'positive' : 'negative';
}
return '';
};
// 増減表示コンポーネント
interface ChangeDisplayProps {
change: number | null;
isPositiveGood: boolean | null;
}
const ChangeDisplay: React.FC<ChangeDisplayProps> = ({
change,
isPositiveGood,
}) => {
if (change === null) return <span>-</span>;
const isImprovement = isPositiveGood === null
? null
: isPositiveGood
? change > 0
: change < 0;
return (
<span className={`change ${isImprovement === true ? 'improved' : isImprovement === false ? 'worsened' : ''}`}>
前期比: {change >= 0 ? '+' : ''}{formatCurrency(change)}
</span>
);
};
19.6 ウォーターフォールチャート¶
CFWaterfallChart コンポーネント¶
// components/CFWaterfallChart.tsx
import { useRef, useEffect, useState } from 'react';
import Decimal from 'decimal.js';
import { formatCurrency } from '../utils/formatUtils';
import type { CFSummary } from '../types/cashFlow';
interface CFWaterfallChartProps {
summary: CFSummary;
title: string;
height?: number;
}
interface WaterfallBar {
label: string;
value: number;
startY: number;
endY: number;
color: string;
isTotal: boolean;
}
export const CFWaterfallChart: React.FC<CFWaterfallChartProps> = ({
summary,
title,
height = 320,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
label: string;
value: number;
} | null>(null);
// バーのデータを計算
const bars: WaterfallBar[] = [];
let runningTotal = summary.beginningBalance;
// 期首残高
bars.push({
label: '期首残高',
value: summary.beginningBalance,
startY: 0,
endY: summary.beginningBalance,
color: '#6b7280',
isTotal: true,
});
// 営業活動CF
const opStart = runningTotal;
runningTotal += summary.operatingCF;
bars.push({
label: '営業活動CF',
value: summary.operatingCF,
startY: opStart,
endY: runningTotal,
color: summary.operatingCF >= 0 ? '#10b981' : '#ef4444',
isTotal: false,
});
// 投資活動CF
const invStart = runningTotal;
runningTotal += summary.investingCF;
bars.push({
label: '投資活動CF',
value: summary.investingCF,
startY: invStart,
endY: runningTotal,
color: summary.investingCF >= 0 ? '#10b981' : '#f59e0b',
isTotal: false,
});
// 財務活動CF
const finStart = runningTotal;
runningTotal += summary.financingCF;
bars.push({
label: '財務活動CF',
value: summary.financingCF,
startY: finStart,
endY: runningTotal,
color: summary.financingCF >= 0 ? '#3b82f6' : '#8b5cf6',
isTotal: false,
});
// 期末残高
bars.push({
label: '期末残高',
value: summary.endingBalance,
startY: 0,
endY: summary.endingBalance,
color: '#111827',
isTotal: true,
});
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// キャンバスサイズ設定
const width = container.clientWidth;
const dpr = window.devicePixelRatio;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
// 描画領域
const padding = { top: 50, right: 30, bottom: 60, left: 100 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 背景クリア
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Y軸の範囲を計算
const allValues = bars.flatMap(b => [b.startY, b.endY]);
const maxValue = Math.max(...allValues, 0);
const minValue = Math.min(...allValues, 0);
const range = maxValue - minValue || 1;
const yPadding = range * 0.1;
const yMax = maxValue + yPadding;
const yMin = minValue - yPadding;
const yRange = yMax - yMin;
// Y座標変換関数
const toY = (value: number): number => {
return padding.top + ((yMax - value) / yRange) * chartHeight;
};
// グリッド線描画
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
const gridCount = 5;
for (let i = 0; i <= gridCount; i++) {
const value = yMax - (yRange * i) / gridCount;
const y = toY(value);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
// Y軸ラベル
ctx.fillStyle = '#6b7280';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(formatCompactCurrency(value), padding.left - 8, y + 4);
}
// ゼロライン
const zeroY = toY(0);
ctx.strokeStyle = '#374151';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding.left, zeroY);
ctx.lineTo(width - padding.right, zeroY);
ctx.stroke();
// バー描画
const barWidth = chartWidth / bars.length * 0.6;
const barGap = chartWidth / bars.length * 0.4;
bars.forEach((bar, index) => {
const x = padding.left + index * (barWidth + barGap) + barGap / 2;
// バーの高さ計算
const y1 = toY(bar.startY);
const y2 = toY(bar.endY);
const barTop = Math.min(y1, y2);
const barHeight = Math.abs(y2 - y1);
// バー描画
ctx.fillStyle = bar.color;
ctx.fillRect(x, barTop, barWidth, barHeight);
// 接続線(非合計バー間)
if (index > 0 && index < bars.length - 1 && !bar.isTotal) {
ctx.strokeStyle = '#9ca3af';
ctx.lineWidth = 1;
ctx.setLineDash([4, 2]);
ctx.beginPath();
ctx.moveTo(x - barGap, y1);
ctx.lineTo(x, y1);
ctx.stroke();
ctx.setLineDash([]);
}
// 値ラベル
const labelY = bar.value >= 0 ? barTop - 8 : barTop + barHeight + 16;
ctx.fillStyle = '#111827';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(
(bar.value >= 0 && !bar.isTotal ? '+' : '') + formatCompactCurrency(bar.value),
x + barWidth / 2,
labelY
);
// X軸ラベル
ctx.fillStyle = '#374151';
ctx.font = '12px sans-serif';
ctx.fillText(bar.label, x + barWidth / 2, height - padding.bottom + 20);
});
// タイトル
ctx.fillStyle = '#111827';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(title, padding.left, 24);
}, [bars, title, height]);
// マウスイベント
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const width = container.clientWidth;
const padding = { left: 100, right: 30 };
const chartWidth = width - padding.left - padding.right;
const barWidth = chartWidth / bars.length * 0.6;
const barGap = chartWidth / bars.length * 0.4;
// どのバーの上にいるか判定
for (let i = 0; i < bars.length; i++) {
const barX = padding.left + i * (barWidth + barGap) + barGap / 2;
if (x >= barX && x <= barX + barWidth) {
setTooltip({
x: e.clientX - rect.left,
y: e.clientY - rect.top - 40,
label: bars[i].label,
value: bars[i].value,
});
return;
}
}
setTooltip(null);
};
const handleMouseLeave = () => {
setTooltip(null);
};
return (
<div ref={containerRef} className="cf-waterfall-chart">
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
{tooltip && (
<div
className="chart-tooltip"
style={{ left: tooltip.x, top: tooltip.y }}
>
<div className="tooltip-label">{tooltip.label}</div>
<div className="tooltip-value">
{tooltip.value >= 0 ? '+' : ''}
{formatCurrency(tooltip.value)}
</div>
</div>
)}
</div>
);
};
// 金額を短縮表示
const formatCompactCurrency = (value: number): string => {
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
if (abs >= 100000000) {
return `${sign}${new Decimal(abs).dividedBy(100000000).toFixed(1)}億`;
}
if (abs >= 10000) {
return `${sign}${new Decimal(abs).dividedBy(10000).toFixed(0)}万`;
}
return `${sign}${abs.toLocaleString()}`;
};
19.7 キャッシュフロー計算書本体¶
CFStatement コンポーネント¶
// components/CFStatement.tsx
import { CFActivitySection } from './CFActivitySection';
import { CFTotalRow } from './CFTotalRow';
import { formatCurrency } from '../utils/formatUtils';
import type { ActivityCashFlow, CFSummary } from '../types/cashFlow';
interface CFStatementProps {
activities: ActivityCashFlow[];
summary: CFSummary;
compareWithPrevious: boolean;
expandedActivities: Set<string>;
onToggleActivity: (activity: string) => void;
}
export const CFStatement: React.FC<CFStatementProps> = ({
activities,
summary,
compareWithPrevious,
expandedActivities,
onToggleActivity,
}) => {
return (
<div className="cf-statement">
<table className="cf-table">
<thead>
<tr>
<th className="col-name">科目</th>
<th className="col-amount">当期金額</th>
{compareWithPrevious && (
<>
<th className="col-amount">前期金額</th>
<th className="col-change">増減</th>
</>
)}
</tr>
</thead>
<tbody>
{/* 各活動区分 */}
{activities.map(activity => (
<CFActivitySection
key={activity.activity}
activity={activity}
isExpanded={expandedActivities.has(activity.activity)}
compareWithPrevious={compareWithPrevious}
onToggle={() => onToggleActivity(activity.activity)}
/>
))}
{/* 現金増減額 */}
<tr className="cf-net-change">
<td className="col-name">
現金及び現金同等物の増減額
</td>
<td className={`col-amount ${summary.netChange >= 0 ? 'positive' : 'negative'}`}>
{formatCurrency(summary.netChange)}
</td>
{compareWithPrevious && (
<>
<td className="col-amount">-</td>
<td className="col-change">-</td>
</>
)}
</tr>
{/* 期首残高 */}
<tr className="cf-balance-row">
<td className="col-name">
現金及び現金同等物の期首残高
</td>
<td className="col-amount">
{formatCurrency(summary.beginningBalance)}
</td>
{compareWithPrevious && (
<>
<td className="col-amount">-</td>
<td className="col-change">-</td>
</>
)}
</tr>
{/* 期末残高 */}
<tr className="cf-balance-row ending">
<td className="col-name">
現金及び現金同等物の期末残高
</td>
<td className="col-amount">
{formatCurrency(summary.endingBalance)}
</td>
{compareWithPrevious && (
<>
<td className="col-amount">-</td>
<td className="col-change">-</td>
</>
)}
</tr>
</tbody>
</table>
</div>
);
};
CFActivitySection コンポーネント¶
// components/CFActivitySection.tsx
import Decimal from 'decimal.js';
import { formatCurrency } from '../utils/formatUtils';
import type { ActivityCashFlow, CFItem } from '../types/cashFlow';
interface CFActivitySectionProps {
activity: ActivityCashFlow;
isExpanded: boolean;
compareWithPrevious: boolean;
onToggle: () => void;
}
const ACTIVITY_NUMBERS: Record<string, string> = {
operating: 'Ⅰ',
investing: 'Ⅱ',
financing: 'Ⅲ',
};
export const CFActivitySection: React.FC<CFActivitySectionProps> = ({
activity,
isExpanded,
compareWithPrevious,
onToggle,
}) => {
// 増減を計算
const calculateChange = (current: number, previous?: number): number | null => {
if (previous === undefined) return null;
return new Decimal(current).minus(previous).toNumber();
};
return (
<>
{/* 活動ヘッダー */}
<tr className={`activity-header ${activity.activity}`}>
<td className="col-name" colSpan={compareWithPrevious ? 4 : 2}>
<button
className="activity-toggle"
onClick={onToggle}
aria-expanded={isExpanded}
>
<span className={`toggle-icon ${isExpanded ? 'expanded' : ''}`}>
▶
</span>
{ACTIVITY_NUMBERS[activity.activity]}. {activity.label}
</button>
</td>
</tr>
{/* 活動内の項目 */}
{isExpanded && activity.items.map(item => (
<CFItemRow
key={item.id}
item={item}
compareWithPrevious={compareWithPrevious}
/>
))}
{/* 活動小計 */}
<tr className={`activity-subtotal ${activity.activity}`}>
<td className="col-name">
{activity.label}
</td>
<td className={`col-amount ${activity.subtotal >= 0 ? 'positive' : 'negative'}`}>
{formatCurrency(activity.subtotal)}
</td>
{compareWithPrevious && (
<>
<td className="col-amount">
{activity.previousSubtotal !== undefined
? formatCurrency(activity.previousSubtotal)
: '-'}
</td>
<td className="col-change">
{activity.previousSubtotal !== undefined && (
<ChangeIndicator
change={calculateChange(activity.subtotal, activity.previousSubtotal)}
/>
)}
</td>
</>
)}
</tr>
</>
);
};
// 項目行コンポーネント
interface CFItemRowProps {
item: CFItem;
compareWithPrevious: boolean;
}
const CFItemRow: React.FC<CFItemRowProps> = ({
item,
compareWithPrevious,
}) => {
const change = item.previousAmount !== undefined
? new Decimal(item.amount).minus(item.previousAmount).toNumber()
: null;
return (
<tr className={`cf-item level-${item.level}`}>
<td className="col-name">
<span style={{ paddingLeft: `${(item.level + 1) * 16}px` }}>
{item.name}
</span>
</td>
<td className="col-amount">
{formatCurrency(item.amount)}
</td>
{compareWithPrevious && (
<>
<td className="col-amount">
{item.previousAmount !== undefined
? formatCurrency(item.previousAmount)
: '-'}
</td>
<td className="col-change">
{change !== null && <ChangeIndicator change={change} />}
</td>
</>
)}
</tr>
);
};
// 増減表示コンポーネント
interface ChangeIndicatorProps {
change: number | null;
}
const ChangeIndicator: React.FC<ChangeIndicatorProps> = ({ change }) => {
if (change === null) return <span>-</span>;
return (
<span className={`change-indicator ${change >= 0 ? 'positive' : 'negative'}`}>
{change >= 0 ? '+' : ''}
{formatCurrency(change)}
</span>
);
};
19.8 資金繰り予測¶
CashFlowForecastContainer¶
// containers/CashFlowForecastContainer.tsx
import { useState, useCallback, useMemo } from 'react';
import { useGetCashFlowForecast } from '../generated/api/cash-flow';
import { CashFlowForecastView } from '../views/CashFlowForecastView';
import { formatDate } from '../utils/dateUtils';
export const CashFlowForecastContainer: React.FC = () => {
const [baseDate, setBaseDate] = useState(formatDate(new Date()));
const [months, setMonths] = useState(3);
const { data, isLoading, error } = useGetCashFlowForecast({
baseDate,
months,
});
// 資金ショートのリスク判定
const riskAssessment = useMemo(() => {
if (!data) return null;
const minBalance = Math.min(...data.forecast.map(f => f.endingBalance));
const alerts: string[] = [];
if (minBalance < 0) {
alerts.push('資金ショートの可能性があります');
} else if (minBalance < data.safetyLine) {
alerts.push('安全ラインを下回る可能性があります');
}
// 大口入金・出金の予定
data.forecast.forEach(month => {
if (month.largeInflows.length > 0) {
alerts.push(`${month.monthLabel}: 大口入金予定あり`);
}
if (month.largeOutflows.length > 0) {
alerts.push(`${month.monthLabel}: 大口出金予定あり`);
}
});
return {
minBalance,
isRisk: minBalance < data.safetyLine,
alerts,
};
}, [data]);
const handleBaseDateChange = useCallback((date: string) => {
setBaseDate(date);
}, []);
const handleMonthsChange = useCallback((m: number) => {
setMonths(m);
}, []);
if (error) {
return <div className="error-state">データの取得に失敗しました</div>;
}
return (
<CashFlowForecastView
data={data}
baseDate={baseDate}
months={months}
riskAssessment={riskAssessment}
isLoading={isLoading}
onBaseDateChange={handleBaseDateChange}
onMonthsChange={handleMonthsChange}
/>
);
};
CashFlowForecastChart コンポーネント¶
// components/CashFlowForecastChart.tsx
import { useRef, useEffect } from 'react';
import Decimal from 'decimal.js';
import { formatCurrency } from '../utils/formatUtils';
interface ForecastDataPoint {
month: string;
monthLabel: string;
beginningBalance: number;
inflows: number;
outflows: number;
endingBalance: number;
isForecast: boolean;
}
interface CashFlowForecastChartProps {
data: ForecastDataPoint[];
safetyLine: number;
height?: number;
}
export const CashFlowForecastChart: React.FC<CashFlowForecastChartProps> = ({
data,
safetyLine,
height = 280,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// キャンバスサイズ設定
const width = container.clientWidth;
const dpr = window.devicePixelRatio;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
// 描画領域
const padding = { top: 40, right: 30, bottom: 50, left: 80 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 背景クリア
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Y軸の範囲を計算
const balances = data.map(d => d.endingBalance);
const maxBalance = Math.max(...balances, safetyLine);
const minBalance = Math.min(...balances, 0);
const range = maxBalance - minBalance || 1;
const yPadding = range * 0.1;
const yMax = maxBalance + yPadding;
const yMin = minBalance - yPadding;
const yRange = yMax - yMin;
// Y座標変換関数
const toY = (value: number): number => {
return padding.top + ((yMax - value) / yRange) * chartHeight;
};
// グリッド線描画
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
const gridCount = 5;
for (let i = 0; i <= gridCount; i++) {
const value = yMax - (yRange * i) / gridCount;
const y = toY(value);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
// Y軸ラベル
ctx.fillStyle = '#6b7280';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(formatCompactCurrency(value), padding.left - 8, y + 4);
}
// 安全ライン
const safetyY = toY(safetyLine);
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(padding.left, safetyY);
ctx.lineTo(width - padding.right, safetyY);
ctx.stroke();
ctx.setLineDash([]);
// 安全ラインラベル
ctx.fillStyle = '#f59e0b';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('安全ライン', padding.left + 4, safetyY - 6);
// X軸設定
const pointGap = chartWidth / (data.length - 1);
// 残高ラインを描画
ctx.beginPath();
data.forEach((point, index) => {
const x = padding.left + index * pointGap;
const y = toY(point.endingBalance);
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.stroke();
// 予測部分を破線で上書き
const forecastStartIndex = data.findIndex(d => d.isForecast);
if (forecastStartIndex > 0) {
ctx.beginPath();
ctx.setLineDash([6, 4]);
for (let i = forecastStartIndex; i < data.length; i++) {
const x = padding.left + i * pointGap;
const y = toY(data[i].endingBalance);
if (i === forecastStartIndex) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = '#93c5fd';
ctx.lineWidth = 2;
ctx.stroke();
ctx.setLineDash([]);
}
// データポイント描画
data.forEach((point, index) => {
const x = padding.left + index * pointGap;
const y = toY(point.endingBalance);
// 円形マーカー
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = point.isForecast ? '#93c5fd' : '#3b82f6';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
// 残高がマイナスの場合は警告色
if (point.endingBalance < 0) {
ctx.beginPath();
ctx.arc(x, y, 7, 0, Math.PI * 2);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.stroke();
}
// X軸ラベル
ctx.fillStyle = '#374151';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(point.monthLabel, x, height - padding.bottom + 20);
});
// 凡例
const legendY = 18;
ctx.font = '11px sans-serif';
// 実績
ctx.fillStyle = '#3b82f6';
ctx.fillRect(width - 180, legendY - 8, 12, 12);
ctx.fillStyle = '#374151';
ctx.textAlign = 'left';
ctx.fillText('実績', width - 164, legendY);
// 予測
ctx.fillStyle = '#93c5fd';
ctx.fillRect(width - 110, legendY - 8, 12, 12);
ctx.fillStyle = '#374151';
ctx.fillText('予測', width - 94, legendY);
}, [data, safetyLine, height]);
return (
<div ref={containerRef} className="cf-forecast-chart">
<canvas ref={canvasRef} />
</div>
);
};
// 金額を短縮表示
const formatCompactCurrency = (value: number): string => {
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
if (abs >= 100000000) {
return `${sign}${new Decimal(abs).dividedBy(100000000).toFixed(1)}億`;
}
if (abs >= 10000) {
return `${sign}${new Decimal(abs).dividedBy(10000).toFixed(0)}万`;
}
return `${sign}${abs.toLocaleString()}`;
};
19.9 月次推移分析¶
CFMonthlyTrendTable コンポーネント¶
// components/CFMonthlyTrendTable.tsx
import { useMemo } from 'react';
import { formatCurrency } from '../utils/formatUtils';
interface MonthlyTrendData {
month: string;
monthLabel: string;
operatingCF: number;
investingCF: number;
financingCF: number;
netChange: number;
endingBalance: number;
}
interface CFMonthlyTrendTableProps {
data: MonthlyTrendData[];
showCumulative: boolean;
}
export const CFMonthlyTrendTable: React.FC<CFMonthlyTrendTableProps> = ({
data,
showCumulative,
}) => {
// 累計データを計算
const displayData = useMemo(() => {
if (!showCumulative) return data;
let cumOperating = 0;
let cumInvesting = 0;
let cumFinancing = 0;
let cumNet = 0;
return data.map(month => {
cumOperating += month.operatingCF;
cumInvesting += month.investingCF;
cumFinancing += month.financingCF;
cumNet += month.netChange;
return {
...month,
operatingCF: cumOperating,
investingCF: cumInvesting,
financingCF: cumFinancing,
netChange: cumNet,
};
});
}, [data, showCumulative]);
// 年間合計を計算
const yearTotal = useMemo(() => ({
operatingCF: data.reduce((sum, m) => sum + m.operatingCF, 0),
investingCF: data.reduce((sum, m) => sum + m.investingCF, 0),
financingCF: data.reduce((sum, m) => sum + m.financingCF, 0),
netChange: data.reduce((sum, m) => sum + m.netChange, 0),
}), [data]);
return (
<div className="cf-monthly-trend-table">
<table>
<thead>
<tr>
<th className="col-label">項目</th>
{displayData.map(month => (
<th key={month.month} className="col-month">
{month.monthLabel}
</th>
))}
<th className="col-total">年間合計</th>
</tr>
</thead>
<tbody>
{/* 営業活動CF */}
<tr className="row-operating">
<td className="col-label">営業活動CF</td>
{displayData.map(month => (
<td
key={month.month}
className={`col-amount ${month.operatingCF >= 0 ? 'positive' : 'negative'}`}
>
{formatCurrency(month.operatingCF)}
</td>
))}
<td className={`col-total ${yearTotal.operatingCF >= 0 ? 'positive' : 'negative'}`}>
{formatCurrency(yearTotal.operatingCF)}
</td>
</tr>
{/* 投資活動CF */}
<tr className="row-investing">
<td className="col-label">投資活動CF</td>
{displayData.map(month => (
<td key={month.month} className="col-amount">
{formatCurrency(month.investingCF)}
</td>
))}
<td className="col-total">
{formatCurrency(yearTotal.investingCF)}
</td>
</tr>
{/* 財務活動CF */}
<tr className="row-financing">
<td className="col-label">財務活動CF</td>
{displayData.map(month => (
<td key={month.month} className="col-amount">
{formatCurrency(month.financingCF)}
</td>
))}
<td className="col-total">
{formatCurrency(yearTotal.financingCF)}
</td>
</tr>
{/* 増減額 */}
<tr className="row-net-change">
<td className="col-label">現金増減額</td>
{displayData.map(month => (
<td
key={month.month}
className={`col-amount ${month.netChange >= 0 ? 'positive' : 'negative'}`}
>
{formatCurrency(month.netChange)}
</td>
))}
<td className={`col-total ${yearTotal.netChange >= 0 ? 'positive' : 'negative'}`}>
{formatCurrency(yearTotal.netChange)}
</td>
</tr>
{/* 期末残高(累計表示の場合のみ) */}
{!showCumulative && (
<tr className="row-ending-balance">
<td className="col-label">期末残高</td>
{displayData.map(month => (
<td key={month.month} className="col-amount">
{formatCurrency(month.endingBalance)}
</td>
))}
<td className="col-total">
{data.length > 0
? formatCurrency(data[data.length - 1].endingBalance)
: '-'}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
19.10 スタイリング¶
/* styles/cash-flow.css */
/* キャッシュフロー計算書ページ */
.cash-flow-view {
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h1 {
margin: 0;
font-size: 24px;
color: #111827;
}
.header-actions {
display: flex;
gap: 8px;
}
/* サマリーパネル */
.cf-summary-panel {
margin-bottom: 24px;
}
.cf-summary-panel h2 {
font-size: 16px;
margin-bottom: 16px;
}
.cf-summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.cf-summary-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s;
}
.cf-summary-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.cf-summary-card.positive {
border-left: 4px solid #10b981;
}
.cf-summary-card.negative {
border-left: 4px solid #ef4444;
}
.card-header {
margin-bottom: 8px;
}
.card-label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.card-description {
display: block;
font-size: 11px;
color: #9ca3af;
margin-top: 2px;
}
.card-amount {
font-size: 24px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
}
.card-change {
font-size: 12px;
color: #6b7280;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
}
.card-change .improved {
color: #10b981;
}
.card-change .worsened {
color: #ef4444;
}
/* 残高フロー */
.cf-balance-summary {
background: #f9fafb;
border-radius: 8px;
padding: 20px;
}
.balance-flow {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.balance-item {
text-align: center;
padding: 12px 20px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.balance-item.ending {
background: #eff6ff;
border-color: #3b82f6;
}
.balance-label {
display: block;
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.balance-amount {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.balance-amount.positive {
color: #10b981;
}
.balance-amount.negative {
color: #ef4444;
}
.balance-arrow {
font-size: 20px;
color: #9ca3af;
}
/* ウォーターフォールチャート */
.cf-waterfall-chart {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
position: relative;
}
.chart-tooltip {
position: absolute;
background: #1f2937;
color: #ffffff;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.tooltip-label {
font-size: 11px;
color: #9ca3af;
margin-bottom: 2px;
}
.tooltip-value {
font-size: 14px;
font-weight: 600;
}
/* 表示コントロール */
.display-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: #f9fafb;
border-radius: 8px;
}
.method-indicator {
font-size: 13px;
color: #6b7280;
}
/* キャッシュフロー計算書テーブル */
.cf-statement {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.cf-table {
width: 100%;
border-collapse: collapse;
}
.cf-table th {
background: #f9fafb;
padding: 12px 16px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.cf-table th.col-amount,
.cf-table th.col-change {
text-align: right;
}
.cf-table td {
padding: 10px 16px;
border-bottom: 1px solid #f3f4f6;
font-size: 14px;
}
.cf-table td.col-amount,
.cf-table td.col-change {
text-align: right;
font-family: 'SF Mono', monospace;
}
/* 活動ヘッダー */
.activity-header {
background: #f9fafb;
}
.activity-header td {
font-weight: 600;
padding: 14px 16px;
}
.activity-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 15px;
color: #111827;
}
.toggle-icon {
font-size: 10px;
transition: transform 0.2s;
}
.toggle-icon.expanded {
transform: rotate(90deg);
}
/* 活動小計 */
.activity-subtotal {
background: #f3f4f6;
font-weight: 600;
}
.activity-subtotal.operating td.col-amount.positive {
color: #10b981;
}
.activity-subtotal.operating td.col-amount.negative {
color: #ef4444;
}
/* 項目行 */
.cf-item {
transition: background-color 0.15s;
}
.cf-item:hover {
background: #f9fafb;
}
/* 増減額・残高行 */
.cf-net-change {
background: #dbeafe;
font-weight: 600;
}
.cf-net-change td.col-amount.positive {
color: #10b981;
}
.cf-net-change td.col-amount.negative {
color: #ef4444;
}
.cf-balance-row {
background: #f9fafb;
}
.cf-balance-row.ending {
background: #eff6ff;
font-weight: 600;
}
/* 増減表示 */
.change-indicator.positive {
color: #10b981;
}
.change-indicator.negative {
color: #ef4444;
}
/* 月次推移テーブル */
.cf-monthly-trend-table {
overflow-x: auto;
}
.cf-monthly-trend-table table {
width: 100%;
min-width: 800px;
border-collapse: collapse;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.cf-monthly-trend-table th,
.cf-monthly-trend-table td {
padding: 10px 12px;
text-align: right;
font-size: 13px;
border-bottom: 1px solid #e5e7eb;
}
.cf-monthly-trend-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
}
.cf-monthly-trend-table .col-label {
text-align: left;
font-weight: 500;
min-width: 120px;
}
.cf-monthly-trend-table .col-total {
background: #f3f4f6;
font-weight: 600;
}
.cf-monthly-trend-table .positive {
color: #10b981;
}
.cf-monthly-trend-table .negative {
color: #ef4444;
}
.cf-monthly-trend-table .row-net-change {
background: #eff6ff;
}
.cf-monthly-trend-table .row-ending-balance {
background: #f9fafb;
font-weight: 600;
}
/* 資金繰り予測チャート */
.cf-forecast-chart {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
}
/* リスクアラート */
.cf-risk-alert {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.cf-risk-alert.warning {
background: #fffbeb;
border-color: #fde68a;
}
.risk-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #dc2626;
margin-bottom: 8px;
}
.risk-title.warning {
color: #d97706;
}
.risk-list {
list-style: none;
padding: 0;
margin: 0;
}
.risk-list li {
padding: 4px 0;
font-size: 13px;
color: #374151;
}
19.11 まとめ¶
本章では、キャッシュフロー計算書の表示機能を実装した。主なポイントは以下の通りである:
- 3つの活動区分: 営業・投資・財務活動ごとにキャッシュフローを区分表示
- 間接法による表示: 税引前当期純利益からの調整形式で営業CFを計算
- ウォーターフォールチャート: 期首残高から期末残高への資金の流れを可視化
- 月次推移分析: 月ごとのキャッシュフロー推移を表形式で表示
- 資金繰り予測: 将来の現金残高予測と安全ラインによるリスク判定
キャッシュフロー計算書は、企業の資金繰りを把握する上で不可欠な財務諸表である。貸借対照表・損益計算書と合わせて三表体制を構成し、企業の財務状況を多角的に分析できる。
これで第6部「財務諸表機能」の実装が完了した。次章以降では、発展的なトピックとして、パフォーマンス最適化やセキュリティ対策などについて解説する。