第18章 損益計算書¶
本章では、損益計算書(Profit and Loss Statement、P/L)の表示機能を実装する。収益・費用の区分表示、段階利益の算出、期間比較分析など、経営判断に必要な情報を提供するコンポーネントを構築していく。
18.1 損益計算書の構造¶
損益計算書の区分¶
損益計算書は、企業の一定期間における経営成績を示す財務諸表である。日本の会計基準では、以下の5つの段階利益を表示する:
売上高
- 売上原価
────────────────
売上総利益(粗利)
- 販売費及び一般管理費
────────────────
営業利益
+ 営業外収益
- 営業外費用
────────────────
経常利益
+ 特別利益
- 特別損失
────────────────
税引前当期純利益
- 法人税等
────────────────
当期純利益
型定義¶
// types/profitAndLoss.ts
/** 損益計算書区分 */
export type PLSection =
| 'sales' // 売上高
| 'costOfSales' // 売上原価
| 'sgAndA' // 販売費及び一般管理費
| 'nonOperatingIncome' // 営業外収益
| 'nonOperatingExpense' // 営業外費用
| 'extraordinaryIncome' // 特別利益
| 'extraordinaryLoss' // 特別損失
| 'incomeTax'; // 法人税等
/** 段階利益種別 */
export type ProfitType =
| 'grossProfit' // 売上総利益
| 'operatingProfit' // 営業利益
| 'ordinaryProfit' // 経常利益
| 'profitBeforeTax' // 税引前当期純利益
| 'netProfit'; // 当期純利益
/** 損益計算書科目 */
export interface PLItem {
id: string;
code: string;
name: string;
section: PLSection;
amount: number;
previousAmount?: number;
budgetAmount?: number;
children?: PLItem[];
level: number;
displayOrder: number;
}
/** 段階利益 */
export interface StagedProfit {
type: ProfitType;
label: string;
amount: number;
previousAmount?: number;
budgetAmount?: number;
ratio?: number; // 売上高比率
}
/** 損益計算書データ */
export interface ProfitAndLossData {
periodStart: string;
periodEnd: string;
items: PLItem[];
profits: StagedProfit[];
totalSales: number;
previousTotalSales?: number;
}
/** 損益計算書検索条件 */
export interface PLSearchParams {
periodStart: string;
periodEnd: string;
compareWithPrevious: boolean;
compareWithBudget: boolean;
departmentId?: string;
projectId?: string;
}
18.2 API 連携¶
OpenAPI 定義¶
# openapi/paths/profit-and-loss.yaml
/api/profit-and-loss:
get:
operationId: getProfitAndLoss
summary: 損益計算書取得
tags:
- ProfitAndLoss
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: compareWithBudget
in: query
schema:
type: boolean
default: false
- name: departmentId
in: query
schema:
type: string
- name: projectId
in: query
schema:
type: string
responses:
'200':
description: 損益計算書
content:
application/json:
schema:
$ref: '#/components/schemas/ProfitAndLossResponse'
/api/profit-and-loss/monthly-trend:
get:
operationId: getPLMonthlyTrend
summary: 損益月次推移取得
tags:
- ProfitAndLoss
parameters:
- name: fiscalYear
in: query
required: true
schema:
type: integer
- name: departmentId
in: query
schema:
type: string
responses:
'200':
description: 月次推移データ
content:
application/json:
schema:
$ref: '#/components/schemas/PLMonthlyTrendResponse'
/api/profit-and-loss/expense-breakdown:
get:
operationId: getExpenseBreakdown
summary: 費用内訳取得
tags:
- ProfitAndLoss
parameters:
- name: periodStart
in: query
required: true
schema:
type: string
format: date
- name: periodEnd
in: query
required: true
schema:
type: string
format: date
- name: section
in: query
required: true
schema:
type: string
enum: [costOfSales, sgAndA]
responses:
'200':
description: 費用内訳
content:
application/json:
schema:
$ref: '#/components/schemas/ExpenseBreakdownResponse'
Orval 生成フック¶
// generated/api/profit-and-loss.ts
import { useQuery } from '@tanstack/react-query';
import type {
ProfitAndLossResponse,
PLMonthlyTrendResponse,
ExpenseBreakdownResponse,
GetProfitAndLossParams,
GetPLMonthlyTrendParams,
GetExpenseBreakdownParams,
} from '../model';
import { apiClient } from '../client';
export const getProfitAndLossQueryKey = (params: GetProfitAndLossParams) =>
['profit-and-loss', params] as const;
export const useGetProfitAndLoss = (
params: GetProfitAndLossParams,
options?: { enabled?: boolean }
) => {
return useQuery({
queryKey: getProfitAndLossQueryKey(params),
queryFn: async () => {
const response = await apiClient.get<ProfitAndLossResponse>(
'/api/profit-and-loss',
{ params }
);
return response.data;
},
...options,
});
};
export const getPLMonthlyTrendQueryKey = (params: GetPLMonthlyTrendParams) =>
['profit-and-loss', 'monthly-trend', params] as const;
export const useGetPLMonthlyTrend = (
params: GetPLMonthlyTrendParams,
options?: { enabled?: boolean }
) => {
return useQuery({
queryKey: getPLMonthlyTrendQueryKey(params),
queryFn: async () => {
const response = await apiClient.get<PLMonthlyTrendResponse>(
'/api/profit-and-loss/monthly-trend',
{ params }
);
return response.data;
},
...options,
});
};
export const getExpenseBreakdownQueryKey = (params: GetExpenseBreakdownParams) =>
['profit-and-loss', 'expense-breakdown', params] as const;
export const useGetExpenseBreakdown = (
params: GetExpenseBreakdownParams,
options?: { enabled?: boolean }
) => {
return useQuery({
queryKey: getExpenseBreakdownQueryKey(params),
queryFn: async () => {
const response = await apiClient.get<ExpenseBreakdownResponse>(
'/api/profit-and-loss/expense-breakdown',
{ params }
);
return response.data;
},
...options,
});
};
18.3 Container 実装¶
ProfitAndLossContainer¶
// containers/ProfitAndLossContainer.tsx
import { useState, useCallback, useMemo } from 'react';
import { useGetProfitAndLoss } from '../generated/api/profit-and-loss';
import { useAccountingPeriod } from '../contexts/AccountingPeriodContext';
import { useMessage } from '../contexts/MessageContext';
import { usePLExport } from '../hooks/usePLExport';
import { ProfitAndLossView } from '../views/ProfitAndLossView';
import type { PLSearchParams } from '../types/profitAndLoss';
import { formatDate, getMonthRange } from '../utils/dateUtils';
export const ProfitAndLossContainer: React.FC = () => {
const { currentPeriod } = useAccountingPeriod();
const { showMessage } = useMessage();
const { exportToPdf, exportToExcel, isExporting } = usePLExport();
// 検索条件
const [searchParams, setSearchParams] = useState<PLSearchParams>(() => {
const { start, end } = getMonthRange(new Date());
return {
periodStart: formatDate(start),
periodEnd: formatDate(end),
compareWithPrevious: false,
compareWithBudget: false,
};
});
// 表示設定
const [showRatio, setShowRatio] = useState(true);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['sales', 'costOfSales', 'sgAndA'])
);
// データ取得
const {
data: plData,
isLoading,
error,
refetch,
} = useGetProfitAndLoss(searchParams);
// 検索実行
const handleSearch = useCallback((params: PLSearchParams) => {
setSearchParams(params);
}, []);
// セクション展開/折りたたみ
const handleToggleSection = useCallback((section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}, []);
// 全展開/全折りたたみ
const handleExpandAll = useCallback(() => {
const allSections = [
'sales', 'costOfSales', 'sgAndA',
'nonOperatingIncome', 'nonOperatingExpense',
'extraordinaryIncome', 'extraordinaryLoss', 'incomeTax'
];
setExpandedSections(new Set(allSections));
}, []);
const handleCollapseAll = useCallback(() => {
setExpandedSections(new Set());
}, []);
// PDF 出力
const handleExportPdf = useCallback(async () => {
if (!plData) return;
try {
await exportToPdf(plData, searchParams);
showMessage('success', 'PDF を出力しました');
} catch (e) {
showMessage('error', 'PDF 出力に失敗しました');
}
}, [plData, searchParams, exportToPdf, showMessage]);
// Excel 出力
const handleExportExcel = useCallback(async () => {
if (!plData) return;
try {
await exportToExcel(plData, searchParams);
showMessage('success', 'Excel を出力しました');
} catch (e) {
showMessage('error', 'Excel 出力に失敗しました');
}
}, [plData, searchParams, exportToExcel, showMessage]);
// 売上高比率計算
const itemsWithRatio = useMemo(() => {
if (!plData) return [];
const totalSales = plData.totalSales;
if (totalSales === 0) return plData.items;
return plData.items.map(item => ({
...item,
ratio: (item.amount / totalSales) * 100,
previousRatio: item.previousAmount && plData.previousTotalSales
? (item.previousAmount / plData.previousTotalSales) * 100
: undefined,
}));
}, [plData]);
if (error) {
return (
<div className="error-container">
<p>データの取得に失敗しました</p>
<button onClick={() => refetch()}>再試行</button>
</div>
);
}
return (
<ProfitAndLossView
data={plData ? { ...plData, items: itemsWithRatio } : undefined}
searchParams={searchParams}
isLoading={isLoading}
isExporting={isExporting}
showRatio={showRatio}
expandedSections={expandedSections}
onSearch={handleSearch}
onToggleSection={handleToggleSection}
onExpandAll={handleExpandAll}
onCollapseAll={handleCollapseAll}
onToggleRatio={() => setShowRatio(prev => !prev)}
onExportPdf={handleExportPdf}
onExportExcel={handleExportExcel}
/>
);
};
18.4 View 実装¶
ProfitAndLossView¶
// views/ProfitAndLossView.tsx
import { PLSearchForm } from '../components/PLSearchForm';
import { PLStatement } from '../components/PLStatement';
import { StagedProfitSummary } from '../components/StagedProfitSummary';
import { LoadingSpinner } from '../components/common/LoadingSpinner';
import type { ProfitAndLossData, PLSearchParams } from '../types/profitAndLoss';
interface ProfitAndLossViewProps {
data?: ProfitAndLossData;
searchParams: PLSearchParams;
isLoading: boolean;
isExporting: boolean;
showRatio: boolean;
expandedSections: Set<string>;
onSearch: (params: PLSearchParams) => void;
onToggleSection: (section: string) => void;
onExpandAll: () => void;
onCollapseAll: () => void;
onToggleRatio: () => void;
onExportPdf: () => void;
onExportExcel: () => void;
}
export const ProfitAndLossView: React.FC<ProfitAndLossViewProps> = ({
data,
searchParams,
isLoading,
isExporting,
showRatio,
expandedSections,
onSearch,
onToggleSection,
onExpandAll,
onCollapseAll,
onToggleRatio,
onExportPdf,
onExportExcel,
}) => {
return (
<div className="profit-and-loss-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>
<PLSearchForm
initialValues={searchParams}
onSearch={onSearch}
isLoading={isLoading}
/>
{isLoading ? (
<LoadingSpinner />
) : data ? (
<div className="pl-content">
{/* 段階利益サマリー */}
<StagedProfitSummary
profits={data.profits}
totalSales={data.totalSales}
compareWithPrevious={searchParams.compareWithPrevious}
compareWithBudget={searchParams.compareWithBudget}
/>
{/* 表示コントロール */}
<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>
<label className="checkbox-label">
<input
type="checkbox"
checked={showRatio}
onChange={onToggleRatio}
/>
売上高比率を表示
</label>
</div>
{/* 損益計算書本体 */}
<PLStatement
items={data.items}
profits={data.profits}
showRatio={showRatio}
compareWithPrevious={searchParams.compareWithPrevious}
compareWithBudget={searchParams.compareWithBudget}
expandedSections={expandedSections}
onToggleSection={onToggleSection}
/>
</div>
) : (
<div className="empty-state">
<p>検索条件を指定して表示してください</p>
</div>
)}
</div>
);
};
18.5 段階利益サマリー¶
StagedProfitSummary コンポーネント¶
// components/StagedProfitSummary.tsx
import Decimal from 'decimal.js';
import { formatCurrency, formatPercent } from '../utils/formatUtils';
import type { StagedProfit } from '../types/profitAndLoss';
interface StagedProfitSummaryProps {
profits: StagedProfit[];
totalSales: number;
compareWithPrevious: boolean;
compareWithBudget: boolean;
}
const PROFIT_LABELS: Record<string, string> = {
grossProfit: '売上総利益',
operatingProfit: '営業利益',
ordinaryProfit: '経常利益',
profitBeforeTax: '税引前当期純利益',
netProfit: '当期純利益',
};
export const StagedProfitSummary: React.FC<StagedProfitSummaryProps> = ({
profits,
totalSales,
compareWithPrevious,
compareWithBudget,
}) => {
// 売上高比率を計算
const calculateRatio = (amount: number): number => {
if (totalSales === 0) return 0;
return new Decimal(amount)
.dividedBy(totalSales)
.times(100)
.toNumber();
};
// 増減率を計算
const calculateChangeRate = (
current: number,
previous?: number
): number | null => {
if (previous === undefined || previous === 0) return null;
return new Decimal(current)
.minus(previous)
.dividedBy(Math.abs(previous))
.times(100)
.toNumber();
};
// 予算達成率を計算
const calculateAchievementRate = (
actual: number,
budget?: number
): number | null => {
if (budget === undefined || budget === 0) return null;
return new Decimal(actual)
.dividedBy(budget)
.times(100)
.toNumber();
};
return (
<div className="staged-profit-summary">
<h2>段階利益サマリー</h2>
<div className="profit-cards">
{profits.map(profit => {
const ratio = calculateRatio(profit.amount);
const changeRate = calculateChangeRate(
profit.amount,
profit.previousAmount
);
const achievementRate = calculateAchievementRate(
profit.amount,
profit.budgetAmount
);
return (
<div
key={profit.type}
className={`profit-card ${profit.amount < 0 ? 'negative' : ''}`}
>
<div className="profit-label">
{PROFIT_LABELS[profit.type]}
</div>
<div className="profit-amount">
{formatCurrency(profit.amount)}
</div>
<div className="profit-ratio">
売上高比率: {formatPercent(ratio)}
</div>
{compareWithPrevious && profit.previousAmount !== undefined && (
<div className="profit-comparison">
<span className="label">前期比:</span>
<span className={`value ${getChangeClass(changeRate)}`}>
{changeRate !== null
? `${changeRate >= 0 ? '+' : ''}${formatPercent(changeRate)}`
: '-'}
</span>
</div>
)}
{compareWithBudget && profit.budgetAmount !== undefined && (
<div className="profit-achievement">
<span className="label">予算達成率:</span>
<span className={`value ${getAchievementClass(achievementRate)}`}>
{achievementRate !== null
? formatPercent(achievementRate)
: '-'}
</span>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
// 増減率のクラス判定
const getChangeClass = (rate: number | null): string => {
if (rate === null) return '';
if (rate > 0) return 'positive';
if (rate < 0) return 'negative';
return '';
};
// 達成率のクラス判定
const getAchievementClass = (rate: number | null): string => {
if (rate === null) return '';
if (rate >= 100) return 'achieved';
if (rate >= 80) return 'warning';
return 'below';
};
18.6 損益計算書本体¶
PLStatement コンポーネント¶
// components/PLStatement.tsx
import { useMemo } from 'react';
import { PLSectionGroup } from './PLSectionGroup';
import { PLProfitRow } from './PLProfitRow';
import { formatCurrency, formatPercent } from '../utils/formatUtils';
import type { PLItem, StagedProfit, PLSection } from '../types/profitAndLoss';
interface PLStatementProps {
items: PLItem[];
profits: StagedProfit[];
showRatio: boolean;
compareWithPrevious: boolean;
compareWithBudget: boolean;
expandedSections: Set<string>;
onToggleSection: (section: string) => void;
}
// セクションの表示順序と構造定義
const PL_STRUCTURE = [
{ section: 'sales' as PLSection, label: '売上高', isRevenue: true },
{ section: 'costOfSales' as PLSection, label: '売上原価', isExpense: true },
{ type: 'profit', profitType: 'grossProfit' },
{ section: 'sgAndA' as PLSection, label: '販売費及び一般管理費', isExpense: true },
{ type: 'profit', profitType: 'operatingProfit' },
{ section: 'nonOperatingIncome' as PLSection, label: '営業外収益', isRevenue: true },
{ section: 'nonOperatingExpense' as PLSection, label: '営業外費用', isExpense: true },
{ type: 'profit', profitType: 'ordinaryProfit' },
{ section: 'extraordinaryIncome' as PLSection, label: '特別利益', isRevenue: true },
{ section: 'extraordinaryLoss' as PLSection, label: '特別損失', isExpense: true },
{ type: 'profit', profitType: 'profitBeforeTax' },
{ section: 'incomeTax' as PLSection, label: '法人税等', isExpense: true },
{ type: 'profit', profitType: 'netProfit' },
];
export const PLStatement: React.FC<PLStatementProps> = ({
items,
profits,
showRatio,
compareWithPrevious,
compareWithBudget,
expandedSections,
onToggleSection,
}) => {
// セクションごとにアイテムをグループ化
const groupedItems = useMemo(() => {
return items.reduce<Record<PLSection, PLItem[]>>((acc, item) => {
if (!acc[item.section]) {
acc[item.section] = [];
}
acc[item.section].push(item);
return acc;
}, {} as Record<PLSection, PLItem[]>);
}, [items]);
// 段階利益をマップ化
const profitMap = useMemo(() => {
return profits.reduce<Record<string, StagedProfit>>((acc, profit) => {
acc[profit.type] = profit;
return acc;
}, {});
}, [profits]);
// 列数を計算
const columnCount = useMemo(() => {
let count = 2; // 科目名 + 当期金額
if (showRatio) count++;
if (compareWithPrevious) count += 2; // 前期金額 + 増減
if (compareWithBudget) count += 2; // 予算 + 達成率
return count;
}, [showRatio, compareWithPrevious, compareWithBudget]);
return (
<div className="pl-statement">
<table className="pl-table">
<thead>
<tr>
<th className="col-name">科目</th>
<th className="col-amount">当期金額</th>
{showRatio && <th className="col-ratio">構成比</th>}
{compareWithPrevious && (
<>
<th className="col-amount">前期金額</th>
<th className="col-change">増減</th>
</>
)}
{compareWithBudget && (
<>
<th className="col-amount">予算</th>
<th className="col-rate">達成率</th>
</>
)}
</tr>
</thead>
<tbody>
{PL_STRUCTURE.map((entry, index) => {
if (entry.type === 'profit') {
const profit = profitMap[entry.profitType!];
if (!profit) return null;
return (
<PLProfitRow
key={entry.profitType}
profit={profit}
showRatio={showRatio}
compareWithPrevious={compareWithPrevious}
compareWithBudget={compareWithBudget}
columnCount={columnCount}
/>
);
}
const sectionItems = groupedItems[entry.section!] || [];
return (
<PLSectionGroup
key={entry.section}
section={entry.section!}
label={entry.label!}
items={sectionItems}
isExpanded={expandedSections.has(entry.section!)}
showRatio={showRatio}
compareWithPrevious={compareWithPrevious}
compareWithBudget={compareWithBudget}
isRevenue={entry.isRevenue}
isExpense={entry.isExpense}
onToggle={() => onToggleSection(entry.section!)}
/>
);
})}
</tbody>
</table>
</div>
);
};
PLSectionGroup コンポーネント¶
// components/PLSectionGroup.tsx
import Decimal from 'decimal.js';
import { formatCurrency, formatPercent } from '../utils/formatUtils';
import type { PLItem, PLSection } from '../types/profitAndLoss';
interface PLSectionGroupProps {
section: PLSection;
label: string;
items: PLItem[];
isExpanded: boolean;
showRatio: boolean;
compareWithPrevious: boolean;
compareWithBudget: boolean;
isRevenue?: boolean;
isExpense?: boolean;
onToggle: () => void;
}
export const PLSectionGroup: React.FC<PLSectionGroupProps> = ({
section,
label,
items,
isExpanded,
showRatio,
compareWithPrevious,
compareWithBudget,
isRevenue,
isExpense,
onToggle,
}) => {
// セクション合計を計算
const sectionTotal = items.reduce(
(sum, item) => sum + item.amount,
0
);
const previousTotal = items.reduce(
(sum, item) => sum + (item.previousAmount || 0),
0
);
const budgetTotal = items.reduce(
(sum, item) => sum + (item.budgetAmount || 0),
0
);
// 増減を計算
const calculateChange = (current: number, previous: number): number => {
return new Decimal(current).minus(previous).toNumber();
};
// 達成率を計算
const calculateRate = (actual: number, budget: number): number | null => {
if (budget === 0) return null;
return new Decimal(actual)
.dividedBy(budget)
.times(100)
.toNumber();
};
// アイテムを階層表示用にソート・フィルタ
const displayItems = items
.filter(item => item.level <= 2 || isExpanded)
.sort((a, b) => a.displayOrder - b.displayOrder);
return (
<>
{/* セクションヘッダー */}
<tr className={`section-header ${section}`}>
<td className="col-name">
<button
className="section-toggle"
onClick={onToggle}
aria-expanded={isExpanded}
>
<span className={`toggle-icon ${isExpanded ? 'expanded' : ''}`}>
▶
</span>
{label}
</button>
</td>
<td className="col-amount section-total">
{formatCurrency(sectionTotal)}
</td>
{showRatio && (
<td className="col-ratio">
{/* セクション合計の比率は段階利益行で表示 */}
</td>
)}
{compareWithPrevious && (
<>
<td className="col-amount">
{formatCurrency(previousTotal)}
</td>
<td className="col-change">
<ChangeIndicator
change={calculateChange(sectionTotal, previousTotal)}
isExpense={isExpense}
/>
</td>
</>
)}
{compareWithBudget && (
<>
<td className="col-amount">
{formatCurrency(budgetTotal)}
</td>
<td className="col-rate">
<RateIndicator
rate={calculateRate(sectionTotal, budgetTotal)}
isExpense={isExpense}
/>
</td>
</>
)}
</tr>
{/* セクション内アイテム */}
{isExpanded && displayItems.map(item => (
<PLItemRow
key={item.id}
item={item}
showRatio={showRatio}
compareWithPrevious={compareWithPrevious}
compareWithBudget={compareWithBudget}
isExpense={isExpense}
/>
))}
</>
);
};
// アイテム行コンポーネント
interface PLItemRowProps {
item: PLItem;
showRatio: boolean;
compareWithPrevious: boolean;
compareWithBudget: boolean;
isExpense?: boolean;
}
const PLItemRow: React.FC<PLItemRowProps> = ({
item,
showRatio,
compareWithPrevious,
compareWithBudget,
isExpense,
}) => {
const change = item.previousAmount !== undefined
? new Decimal(item.amount).minus(item.previousAmount).toNumber()
: null;
const rate = item.budgetAmount && item.budgetAmount !== 0
? new Decimal(item.amount)
.dividedBy(item.budgetAmount)
.times(100)
.toNumber()
: null;
return (
<tr className={`pl-item level-${item.level}`}>
<td className="col-name">
<span style={{ paddingLeft: `${item.level * 16}px` }}>
{item.name}
</span>
</td>
<td className="col-amount">
{formatCurrency(item.amount)}
</td>
{showRatio && (
<td className="col-ratio">
{(item as any).ratio !== undefined
? formatPercent((item as any).ratio)
: '-'}
</td>
)}
{compareWithPrevious && (
<>
<td className="col-amount">
{item.previousAmount !== undefined
? formatCurrency(item.previousAmount)
: '-'}
</td>
<td className="col-change">
{change !== null && (
<ChangeIndicator change={change} isExpense={isExpense} />
)}
</td>
</>
)}
{compareWithBudget && (
<>
<td className="col-amount">
{item.budgetAmount !== undefined
? formatCurrency(item.budgetAmount)
: '-'}
</td>
<td className="col-rate">
{rate !== null && (
<RateIndicator rate={rate} isExpense={isExpense} />
)}
</td>
</>
)}
</tr>
);
};
// 増減表示コンポーネント
interface ChangeIndicatorProps {
change: number;
isExpense?: boolean;
}
const ChangeIndicator: React.FC<ChangeIndicatorProps> = ({
change,
isExpense,
}) => {
// 費用の場合、増加は悪い(赤)、減少は良い(緑)
// 収益の場合、増加は良い(緑)、減少は悪い(赤)
const isPositive = isExpense ? change < 0 : change > 0;
const isNegative = isExpense ? change > 0 : change < 0;
return (
<span
className={`change-indicator ${
isPositive ? 'positive' : isNegative ? 'negative' : ''
}`}
>
{change >= 0 ? '+' : ''}
{formatCurrency(change)}
</span>
);
};
// 達成率表示コンポーネント
interface RateIndicatorProps {
rate: number | null;
isExpense?: boolean;
}
const RateIndicator: React.FC<RateIndicatorProps> = ({ rate, isExpense }) => {
if (rate === null) return <span>-</span>;
// 費用の場合、100%以下が良い
// 収益の場合、100%以上が良い
const isGood = isExpense ? rate <= 100 : rate >= 100;
const isBad = isExpense ? rate > 100 : rate < 100;
return (
<span
className={`rate-indicator ${
isGood ? 'good' : isBad ? 'bad' : ''
}`}
>
{formatPercent(rate)}
</span>
);
};
PLProfitRow コンポーネント¶
// components/PLProfitRow.tsx
import Decimal from 'decimal.js';
import { formatCurrency, formatPercent } from '../utils/formatUtils';
import type { StagedProfit } from '../types/profitAndLoss';
interface PLProfitRowProps {
profit: StagedProfit;
showRatio: boolean;
compareWithPrevious: boolean;
compareWithBudget: boolean;
columnCount: number;
}
const PROFIT_LABELS: Record<string, string> = {
grossProfit: '売上総利益',
operatingProfit: '営業利益',
ordinaryProfit: '経常利益',
profitBeforeTax: '税引前当期純利益',
netProfit: '当期純利益',
};
export const PLProfitRow: React.FC<PLProfitRowProps> = ({
profit,
showRatio,
compareWithPrevious,
compareWithBudget,
columnCount,
}) => {
// 増減を計算
const change = profit.previousAmount !== undefined
? new Decimal(profit.amount).minus(profit.previousAmount).toNumber()
: null;
// 増減率を計算
const changeRate = profit.previousAmount && profit.previousAmount !== 0
? new Decimal(profit.amount)
.minus(profit.previousAmount)
.dividedBy(Math.abs(profit.previousAmount))
.times(100)
.toNumber()
: null;
// 達成率を計算
const achievementRate = profit.budgetAmount && profit.budgetAmount !== 0
? new Decimal(profit.amount)
.dividedBy(profit.budgetAmount)
.times(100)
.toNumber()
: null;
return (
<tr className={`profit-row ${profit.type} ${profit.amount < 0 ? 'negative' : ''}`}>
<td className="col-name profit-name">
{PROFIT_LABELS[profit.type]}
</td>
<td className="col-amount profit-amount">
{formatCurrency(profit.amount)}
</td>
{showRatio && (
<td className="col-ratio profit-ratio">
{profit.ratio !== undefined
? formatPercent(profit.ratio)
: '-'}
</td>
)}
{compareWithPrevious && (
<>
<td className="col-amount">
{profit.previousAmount !== undefined
? formatCurrency(profit.previousAmount)
: '-'}
</td>
<td className="col-change">
{change !== null && (
<span className={`change ${change >= 0 ? 'positive' : 'negative'}`}>
{change >= 0 ? '+' : ''}
{formatCurrency(change)}
{changeRate !== null && (
<span className="change-rate">
({changeRate >= 0 ? '+' : ''}{formatPercent(changeRate)})
</span>
)}
</span>
)}
</td>
</>
)}
{compareWithBudget && (
<>
<td className="col-amount">
{profit.budgetAmount !== undefined
? formatCurrency(profit.budgetAmount)
: '-'}
</td>
<td className="col-rate">
{achievementRate !== null && (
<span
className={`achievement-rate ${
achievementRate >= 100 ? 'achieved' : 'not-achieved'
}`}
>
{formatPercent(achievementRate)}
</span>
)}
</td>
</>
)}
</tr>
);
};
18.7 月次推移分析¶
PLMonthlyTrendContainer¶
// containers/PLMonthlyTrendContainer.tsx
import { useState, useCallback, useMemo } from 'react';
import { useGetPLMonthlyTrend } from '../generated/api/profit-and-loss';
import { useAccountingPeriod } from '../contexts/AccountingPeriodContext';
import { PLMonthlyTrendView } from '../views/PLMonthlyTrendView';
import type { ProfitType } from '../types/profitAndLoss';
export const PLMonthlyTrendContainer: React.FC = () => {
const { currentPeriod } = useAccountingPeriod();
const [fiscalYear, setFiscalYear] = useState(currentPeriod?.year || new Date().getFullYear());
const [selectedProfitType, setSelectedProfitType] = useState<ProfitType>('operatingProfit');
const [showCumulative, setShowCumulative] = useState(false);
const { data, isLoading, error } = useGetPLMonthlyTrend({ fiscalYear });
// 選択した利益タイプの月次データを抽出
const monthlyData = useMemo(() => {
if (!data) return [];
return data.months.map(month => {
const profit = month.profits.find(p => p.type === selectedProfitType);
return {
month: month.month,
monthLabel: month.monthLabel,
amount: profit?.amount || 0,
budgetAmount: profit?.budgetAmount || 0,
previousYearAmount: profit?.previousYearAmount || 0,
};
});
}, [data, selectedProfitType]);
// 累計データを計算
const cumulativeData = useMemo(() => {
if (!showCumulative) return monthlyData;
let cumAmount = 0;
let cumBudget = 0;
let cumPrevious = 0;
return monthlyData.map(month => {
cumAmount += month.amount;
cumBudget += month.budgetAmount;
cumPrevious += month.previousYearAmount;
return {
...month,
amount: cumAmount,
budgetAmount: cumBudget,
previousYearAmount: cumPrevious,
};
});
}, [monthlyData, showCumulative]);
const handleYearChange = useCallback((year: number) => {
setFiscalYear(year);
}, []);
const handleProfitTypeChange = useCallback((type: ProfitType) => {
setSelectedProfitType(type);
}, []);
const handleToggleCumulative = useCallback(() => {
setShowCumulative(prev => !prev);
}, []);
if (error) {
return <div className="error-state">データの取得に失敗しました</div>;
}
return (
<PLMonthlyTrendView
fiscalYear={fiscalYear}
selectedProfitType={selectedProfitType}
showCumulative={showCumulative}
data={cumulativeData}
salesData={data?.salesMonthly || []}
isLoading={isLoading}
onYearChange={handleYearChange}
onProfitTypeChange={handleProfitTypeChange}
onToggleCumulative={handleToggleCumulative}
/>
);
};
PLTrendChart コンポーネント¶
// components/PLTrendChart.tsx
import { useRef, useEffect } from 'react';
import Decimal from 'decimal.js';
import { formatCurrency } from '../utils/formatUtils';
interface MonthlyDataPoint {
month: number;
monthLabel: string;
amount: number;
budgetAmount: number;
previousYearAmount: number;
}
interface PLTrendChartProps {
data: MonthlyDataPoint[];
title: string;
height?: number;
showBudget?: boolean;
showPreviousYear?: boolean;
}
export const PLTrendChart: React.FC<PLTrendChartProps> = ({
data,
title,
height = 300,
showBudget = true,
showPreviousYear = true,
}) => {
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;
canvas.width = width * window.devicePixelRatio;
canvas.height = height * window.devicePixelRatio;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
// 描画領域
const padding = { top: 40, right: 20, bottom: 40, 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);
// データ範囲を計算
const allValues = data.flatMap(d => [
d.amount,
showBudget ? d.budgetAmount : 0,
showPreviousYear ? d.previousYearAmount : 0,
]);
const maxValue = Math.max(...allValues, 0);
const minValue = Math.min(...allValues, 0);
const range = maxValue - minValue || 1;
const yScale = chartHeight / (range * 1.1);
const yOffset = maxValue + range * 0.05;
// グリッド線描画
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
const gridCount = 5;
for (let i = 0; i <= gridCount; i++) {
const y = padding.top + (chartHeight * i) / gridCount;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
// Y軸ラベル
const value = yOffset - (range * 1.1 * i) / gridCount;
ctx.fillStyle = '#6b7280';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(formatCompactCurrency(value), padding.left - 8, y + 4);
}
// ゼロライン
if (minValue < 0) {
const zeroY = padding.top + (yOffset - 0) * yScale;
ctx.strokeStyle = '#374151';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding.left, zeroY);
ctx.lineTo(width - padding.right, zeroY);
ctx.stroke();
}
// X軸設定
const barGroupWidth = chartWidth / data.length;
const barWidth = barGroupWidth * 0.2;
// データ描画
data.forEach((point, index) => {
const x = padding.left + barGroupWidth * index + barGroupWidth / 2;
// X軸ラベル
ctx.fillStyle = '#6b7280';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(point.monthLabel, x, height - padding.bottom + 20);
// 棒グラフ描画関数
const drawBar = (value: number, offsetX: number, color: string) => {
const barHeight = Math.abs(value) * yScale;
const barY = value >= 0
? padding.top + (yOffset - value) * yScale
: padding.top + yOffset * yScale;
ctx.fillStyle = color;
ctx.fillRect(x + offsetX - barWidth / 2, barY, barWidth, barHeight);
};
// 当期実績
drawBar(point.amount, 0, '#3b82f6');
// 予算
if (showBudget) {
drawBar(point.budgetAmount, barWidth + 4, '#10b981');
}
// 前年実績
if (showPreviousYear) {
drawBar(point.previousYearAmount, (barWidth + 4) * 2, '#9ca3af');
}
});
// 凡例
const legendY = 20;
const legendItems = [
{ label: '当期', color: '#3b82f6' },
...(showBudget ? [{ label: '予算', color: '#10b981' }] : []),
...(showPreviousYear ? [{ label: '前年', color: '#9ca3af' }] : []),
];
let legendX = width - padding.right - 20;
legendItems.reverse().forEach(item => {
ctx.font = '12px sans-serif';
const textWidth = ctx.measureText(item.label).width;
legendX -= textWidth + 24;
ctx.fillStyle = item.color;
ctx.fillRect(legendX, legendY - 8, 12, 12);
ctx.fillStyle = '#374151';
ctx.textAlign = 'left';
ctx.fillText(item.label, legendX + 16, legendY);
});
// タイトル
ctx.fillStyle = '#111827';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(title, padding.left, 20);
}, [data, title, height, showBudget, showPreviousYear]);
return (
<div ref={containerRef} className="pl-trend-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()}`;
};
18.8 費用内訳分析¶
ExpenseBreakdownContainer¶
// containers/ExpenseBreakdownContainer.tsx
import { useState, useCallback, useMemo } from 'react';
import { useGetExpenseBreakdown } from '../generated/api/profit-and-loss';
import { ExpenseBreakdownView } from '../views/ExpenseBreakdownView';
import type { PLSection } from '../types/profitAndLoss';
interface ExpenseBreakdownContainerProps {
periodStart: string;
periodEnd: string;
}
export const ExpenseBreakdownContainer: React.FC<ExpenseBreakdownContainerProps> = ({
periodStart,
periodEnd,
}) => {
const [selectedSection, setSelectedSection] = useState<'costOfSales' | 'sgAndA'>('sgAndA');
const [sortBy, setSortBy] = useState<'amount' | 'ratio'>('amount');
const [showTop, setShowTop] = useState<number>(10);
const { data, isLoading } = useGetExpenseBreakdown({
periodStart,
periodEnd,
section: selectedSection,
});
// ソート・フィルタリング
const processedData = useMemo(() => {
if (!data) return [];
const sorted = [...data.items].sort((a, b) => {
if (sortBy === 'amount') {
return b.amount - a.amount;
}
return b.ratio - a.ratio;
});
return sorted.slice(0, showTop);
}, [data, sortBy, showTop]);
// その他の合計
const othersTotal = useMemo(() => {
if (!data) return 0;
const topTotal = processedData.reduce((sum, item) => sum + item.amount, 0);
return data.total - topTotal;
}, [data, processedData]);
const handleSectionChange = useCallback((section: 'costOfSales' | 'sgAndA') => {
setSelectedSection(section);
}, []);
const handleSortChange = useCallback((sort: 'amount' | 'ratio') => {
setSortBy(sort);
}, []);
const handleTopChange = useCallback((top: number) => {
setShowTop(top);
}, []);
return (
<ExpenseBreakdownView
selectedSection={selectedSection}
sortBy={sortBy}
showTop={showTop}
data={processedData}
total={data?.total || 0}
othersTotal={othersTotal}
isLoading={isLoading}
onSectionChange={handleSectionChange}
onSortChange={handleSortChange}
onTopChange={handleTopChange}
/>
);
};
ExpenseBreakdownChart(円グラフ)¶
// components/ExpenseBreakdownChart.tsx
import { useRef, useEffect, useState } from 'react';
import Decimal from 'decimal.js';
import { formatCurrency, formatPercent } from '../utils/formatUtils';
interface ExpenseItem {
id: string;
name: string;
amount: number;
ratio: number;
}
interface ExpenseBreakdownChartProps {
data: ExpenseItem[];
othersAmount: number;
total: number;
title: string;
}
const COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
];
export const ExpenseBreakdownChart: React.FC<ExpenseBreakdownChartProps> = ({
data,
othersAmount,
total,
title,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
content: string;
} | null>(null);
// 描画データを準備(その他を含む)
const chartData = [
...data.map((item, index) => ({
...item,
color: COLORS[index % COLORS.length],
})),
...(othersAmount > 0 ? [{
id: 'others',
name: 'その他',
amount: othersAmount,
ratio: total > 0 ? (othersAmount / total) * 100 : 0,
color: '#9ca3af',
}] : []),
];
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const size = 280;
const dpr = window.devicePixelRatio;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
const centerX = size / 2;
const centerY = size / 2;
const radius = 100;
const innerRadius = 50;
// 背景クリア
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
// 円グラフ描画
let currentAngle = -Math.PI / 2; // 12時の位置から開始
chartData.forEach((item, index) => {
const sliceAngle = (item.ratio / 100) * Math.PI * 2;
const isHovered = hoveredIndex === index;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
// ホバー時は少し拡大
const r = isHovered ? radius + 5 : radius;
const ir = isHovered ? innerRadius - 2 : innerRadius;
ctx.arc(centerX, centerY, r, currentAngle, currentAngle + sliceAngle);
ctx.arc(centerX, centerY, ir, currentAngle + sliceAngle, currentAngle, true);
ctx.closePath();
ctx.fillStyle = item.color;
ctx.fill();
// ホバー時は境界線を追加
if (isHovered) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
}
currentAngle += sliceAngle;
});
// 中央テキスト
ctx.fillStyle = '#111827';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('合計', centerX, centerY - 10);
ctx.font = '14px sans-serif';
ctx.fillText(formatCompactAmount(total), centerX, centerY + 10);
}, [chartData, total, hoveredIndex]);
// マウスイベントハンドラ
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = 140;
const centerY = 140;
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
// ドーナツの範囲内かチェック
if (distance < 50 || distance > 100) {
setHoveredIndex(null);
setTooltip(null);
return;
}
// 角度を計算
let angle = Math.atan2(dy, dx) + Math.PI / 2;
if (angle < 0) angle += Math.PI * 2;
const percentage = (angle / (Math.PI * 2)) * 100;
// どのセグメントかを判定
let cumulative = 0;
for (let i = 0; i < chartData.length; i++) {
cumulative += chartData[i].ratio;
if (percentage <= cumulative) {
setHoveredIndex(i);
setTooltip({
x: e.clientX - rect.left + 10,
y: e.clientY - rect.top - 30,
content: `${chartData[i].name}: ${formatCurrency(chartData[i].amount)} (${formatPercent(chartData[i].ratio)})`,
});
return;
}
}
};
const handleMouseLeave = () => {
setHoveredIndex(null);
setTooltip(null);
};
return (
<div className="expense-breakdown-chart">
<h3>{title}</h3>
<div className="chart-container">
<div className="chart-canvas-wrapper">
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
{tooltip && (
<div
className="chart-tooltip"
style={{ left: tooltip.x, top: tooltip.y }}
>
{tooltip.content}
</div>
)}
</div>
<div className="chart-legend">
{chartData.map((item, index) => (
<div
key={item.id}
className={`legend-item ${hoveredIndex === index ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<span
className="legend-color"
style={{ backgroundColor: item.color }}
/>
<span className="legend-name">{item.name}</span>
<span className="legend-ratio">{formatPercent(item.ratio)}</span>
</div>
))}
</div>
</div>
</div>
);
};
const formatCompactAmount = (value: number): string => {
if (value >= 100000000) {
return `${new Decimal(value).dividedBy(100000000).toFixed(1)}億円`;
}
if (value >= 10000) {
return `${new Decimal(value).dividedBy(10000).toFixed(0)}万円`;
}
return `${value.toLocaleString()}円`;
};
18.9 スタイリング¶
/* styles/profit-and-loss.css */
/* 損益計算書ページ */
.profit-and-loss-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;
}
/* 段階利益サマリー */
.staged-profit-summary {
margin-bottom: 24px;
}
.staged-profit-summary h2 {
font-size: 16px;
margin-bottom: 16px;
}
.profit-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.profit-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s;
}
.profit-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.profit-card.negative {
background: #fef2f2;
border-color: #fecaca;
}
.profit-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.profit-amount {
font-size: 24px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
}
.profit-card.negative .profit-amount {
color: #dc2626;
}
.profit-ratio {
font-size: 12px;
color: #6b7280;
}
.profit-comparison,
.profit-achievement {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
}
.profit-comparison .value.positive {
color: #10b981;
}
.profit-comparison .value.negative {
color: #dc2626;
}
.profit-achievement .value.achieved {
color: #10b981;
}
.profit-achievement .value.warning {
color: #f59e0b;
}
.profit-achievement .value.below {
color: #dc2626;
}
/* 表示コントロール */
.display-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: #f9fafb;
border-radius: 8px;
}
.control-group {
display: flex;
gap: 16px;
}
.btn-text {
background: none;
border: none;
color: #3b82f6;
cursor: pointer;
font-size: 14px;
}
.btn-text:hover {
text-decoration: underline;
}
/* 損益計算書テーブル */
.pl-statement {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.pl-table {
width: 100%;
border-collapse: collapse;
}
.pl-table th {
background: #f9fafb;
padding: 12px 16px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.pl-table th.col-amount,
.pl-table th.col-ratio,
.pl-table th.col-change,
.pl-table th.col-rate {
text-align: right;
}
.pl-table td {
padding: 10px 16px;
border-bottom: 1px solid #f3f4f6;
font-size: 14px;
}
.pl-table td.col-amount,
.pl-table td.col-ratio,
.pl-table td.col-change,
.pl-table td.col-rate {
text-align: right;
font-family: 'SF Mono', monospace;
}
/* セクションヘッダー */
.section-header {
background: #f9fafb;
}
.section-header td {
font-weight: 600;
}
.section-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 14px;
color: #111827;
}
.toggle-icon {
font-size: 10px;
transition: transform 0.2s;
}
.toggle-icon.expanded {
transform: rotate(90deg);
}
/* アイテム行 */
.pl-item {
transition: background-color 0.15s;
}
.pl-item:hover {
background: #f9fafb;
}
.pl-item.level-0 .col-name {
font-weight: 600;
}
.pl-item.level-1 .col-name {
color: #374151;
}
.pl-item.level-2 .col-name {
color: #6b7280;
font-size: 13px;
}
/* 段階利益行 */
.profit-row {
background: #eff6ff;
font-weight: 600;
}
.profit-row.netProfit {
background: #dbeafe;
}
.profit-row.negative {
background: #fef2f2;
}
.profit-name {
color: #1d4ed8;
}
.profit-row.negative .profit-name {
color: #dc2626;
}
/* 増減表示 */
.change-indicator.positive {
color: #10b981;
}
.change-indicator.negative {
color: #dc2626;
}
/* 達成率表示 */
.rate-indicator.good {
color: #10b981;
}
.rate-indicator.bad {
color: #dc2626;
}
/* 月次推移チャート */
.pl-trend-chart {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
}
/* 費用内訳チャート */
.expense-breakdown-chart {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
}
.expense-breakdown-chart h3 {
font-size: 16px;
margin: 0 0 16px 0;
}
.chart-container {
display: flex;
gap: 24px;
align-items: flex-start;
}
.chart-canvas-wrapper {
position: relative;
}
.chart-tooltip {
position: absolute;
background: #1f2937;
color: #ffffff;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
.chart-legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
}
.legend-item:hover,
.legend-item.hovered {
background: #f3f4f6;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-name {
flex: 1;
font-size: 13px;
color: #374151;
}
.legend-ratio {
font-size: 13px;
color: #6b7280;
font-family: 'SF Mono', monospace;
}
18.10 まとめ¶
本章では、損益計算書の表示機能を実装した。主なポイントは以下の通りである:
- 段階利益の表示: 売上総利益から当期純利益まで、5つの段階利益を明確に表示
- 売上高比率分析: 各科目の売上高に対する構成比を表示し、収益性を分析
- 期間比較: 前期比較・予算比較により、業績の推移と達成状況を把握
- 費用内訳分析: 円グラフによる費用構成の可視化
- 月次推移: 棒グラフによる月次業績推移の表示
損益計算書は、企業の収益力を示す重要な財務諸表であり、経営判断に欠かせない情報を提供する。次章では、キャッシュフロー計算書の実装について解説する。