カスタムAPIを使ってリアルタイム売上ダッシュボードを作成するサンプルプログラムを紹介します。
本記事は「最小構成で動作検証 → 実DB連携」の2段階で解説します。
開発初期はカスタムAPI(モックデータ)でUIと動作をすばやく検証し、
その後に本番データへ差し替えることで開発スピードと品質の両立を図れます。
簡易実装パターン(モックデータ)
まずは実DBを使わずにモックデータを返すAPIで画面イメージと基本動作を確認します。
UI検討や初期実装では、この方法で素早く動作を確認することを推奨します。
簡易実装パターンでは、カスタムAPI用ライブラリの設定をする必要はありません。
HTML
以下のコードを認証エリアのページのbodyタブに配置してください。
<div class="dashboard-container">
<div class="dashboard-header">
<h1 class="dashboard-title">リアルタイムデータ可視化ダッシュボード</h1>
<div class="dashboard-controls">
<select id="interval-select" class="dashboard-interval">
<option value="5000">5秒</option>
<option value="10000" selected>10秒</option>
<option value="30000">30秒</option>
<option value="60000">1分</option>
</select>
<button id="refresh-button" class="dashboard-refresh">今すぐ更新</button>
</div>
</div>
<div id="last-updated" class="dashboard-status">最終更新: -</div>
<div class="dashboard-summary">
<div class="summary-card">
<div class="summary-title">総売上</div>
<div id="total-sales" class="summary-value">¥0</div>
<div id="sales-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
<div class="summary-card">
<div class="summary-title">注文数</div>
<div id="total-orders" class="summary-value">0</div>
<div id="orders-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
<div class="summary-card">
<div class="summary-title">平均注文額</div>
<div id="average-order" class="summary-value">¥0</div>
<div id="average-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
<div class="summary-card">
<div class="summary-title">新規顧客</div>
<div id="new-customers" class="summary-value">0</div>
<div id="customers-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">時間帯別売上</h2>
</div>
<div class="dashboard-chart-container">
<canvas id="sales-chart"></canvas>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">カテゴリ別売上</h2>
</div>
<div class="dashboard-chart-container">
<canvas id="category-chart"></canvas>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">地域別売上</h2>
</div>
<div class="dashboard-chart-container">
<canvas id="region-chart"></canvas>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">最近の注文</h2>
</div>
<table class="dashboard-table">
<thead>
<tr>
<th>注文ID</th>
<th>顧客名</th>
<th>商品</th>
<th>金額</th>
<th>状態</th>
</tr>
</thead>
<tbody id="recent-orders">
<tr>
<td colspan="5">データを読み込み中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="loading" class="dashboard-loading">
<div class="dashboard-spinner"></div>
</div>
<a href="#" data-logout>Logout</a>
HEAD
次にheadタブへ以下を追加します。
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${page.title}"></title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
CSS
ダッシュボードのスタイルはCSSタブに登録してください。
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
color: #333;
}
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.dashboard-title {
margin: 0;
color: #2c3e50;
}
.dashboard-controls {
display: flex;
gap: 10px;
}
.dashboard-refresh {
background-color: #3498db;
color: white;
border: none;
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.dashboard-refresh:hover {
background-color: #2980b9;
}
.dashboard-interval {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.dashboard-status {
font-size: 14px;
color: #7f8c8d;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.dashboard-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.dashboard-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.dashboard-card-title {
margin: 0;
font-size: 18px;
color: #2c3e50;
}
.dashboard-card-value {
font-size: 24px;
font-weight: bold;
color: #3498db;
}
.dashboard-chart-container {
position: relative;
height: 300px;
}
.dashboard-table {
width: 100%;
border-collapse: collapse;
}
.dashboard-table th,
.dashboard-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.dashboard-table th {
background-color: #f8f9fa;
font-weight: bold;
color: #2c3e50;
}
.dashboard-table tr:hover {
background-color: #f5f7fa;
}
.dashboard-loading {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.dashboard-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #3498db;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.positive-trend {
color: #27ae60;
}
.negative-trend {
color: #e74c3c;
}
.trend-indicator {
margin-left: 5px;
}
.dashboard-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.summary-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
text-align: center;
}
.summary-title {
font-size: 14px;
color: #7f8c8d;
margin-bottom: 10px;
}
.summary-value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 5px;
}
.summary-change {
font-size: 14px;
}
JavaScript
重要: API_ENDPOINTには、作成したカスタムAPI(モックデータ)のURLを設定します。
// ダッシュボード用のシンプルなJavaScript
document.addEventListener('DOMContentLoaded', function() {
// カスタムAPIのエンドポイント(実際の環境に合わせて変更してください)
const API_ENDPOINT = '/_program/dashbord_mock';
// 更新間隔(ミリ秒)
let updateInterval = 30000; // デフォルト: 30秒
let updateTimer = null;
// Chart.jsのグラフインスタンス
let hourlyChart = null;
let categoryChart = null;
let regionChart = null;
// 初期化関数
function initialize() {
// 更新間隔の設定
document.getElementById('interval-select').addEventListener('change', function() {
updateInterval = parseInt(this.value);
restartUpdateTimer();
});
// 初回データ取得
fetchAllData();
// 定期更新の開始
startUpdateTimer();
}
// 更新タイマーの開始
function startUpdateTimer() {
updateTimer = setInterval(fetchAllData, updateInterval);
}
// 更新タイマーの再起動
function restartUpdateTimer() {
if (updateTimer) {
clearInterval(updateTimer);
}
startUpdateTimer();
}
// すべてのデータを取得
function fetchAllData() {
fetchSalesSummary();
fetchHourlySales();
fetchCategorySales();
fetchRegionSales();
fetchRecentOrders();
// 最終更新時刻の表示
const now = new Date();
document.getElementById('last-updated').textContent =
`最終更新: ${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
}
// APIリクエストを実行する関数
function callApi(action, params = {}) {
showLoading(`${action}-container`);
// SPIRALのカスタムAPI用のリクエストデータを作成
const requestData = {
params: {
action: action,
...params
}
};
// Fetch APIを使用してリクエスト
return fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
if (data.status === 'success' && data.data.data.success) {
return data.data.data;
} else {
throw new Error(data.data.error || 'APIリクエストに失敗しました');
}
})
.catch(error => {
console.error(`${action}データの取得中にエラーが発生しました:`, error);
hideLoading(`${action}-container`);
throw error;
});
}
// 売上サマリーデータの取得
function fetchSalesSummary() {
showLoading('summary-container');
callApi('getSalesSummary')
.then(data => {
updateSalesSummary(data.data.result);
hideLoading('summary-container');
})
.catch(() => {
// エラーハンドリングは既にcallApi内で行っているため、ここでは何もしない
});
}
// 時間帯別売上データの取得
function fetchHourlySales() {
showLoading('hourly-chart-container');
callApi('getHourlySales')
.then(data => {
updateHourlySalesChart(data.data.result.hourly);
hideLoading('hourly-chart-container');
})
.catch(() => {
// エラーハンドリングは既にcallApi内で行っているため、ここでは何もしない
});
}
// カテゴリ別売上データの取得
function fetchCategorySales() {
showLoading('category-chart-container');
callApi('getCategorySales')
.then(data => {
updateCategorySalesChart(data.data.result.categories);
hideLoading('category-chart-container');
})
.catch(() => {
// エラーハンドリングは既にcallApi内で行っているため、ここでは何もしない
});
}
// 地域別売上データの取得
function fetchRegionSales() {
showLoading('region-chart-container');
callApi('getRegionSales')
.then(data => {
updateRegionSalesChart(data.data.result.regions);
hideLoading('region-chart-container');
})
.catch(() => {
// エラーハンドリングは既にcallApi内で行っているため、ここでは何もしない
});
}
// 最近の注文データの取得
function fetchRecentOrders() {
showLoading('recent-orders-container');
callApi('getRecentOrders')
.then(data => {
updateRecentOrdersTable(data.data.result.orders);
hideLoading('recent-orders-container');
})
.catch(() => {
// エラーハンドリングは既にcallApi内で行っているため、ここでは何もしない
});
}
// 売上サマリーの更新
function updateSalesSummary(data) {
document.getElementById('total-sales').textContent = formatCurrency(data.totalSales);
document.getElementById('sales-change').textContent = formatPercentage(data.salesChangePercent);
document.getElementById('sales-change').className = getChangeClass(data.salesChangePercent);
document.getElementById('total-orders').textContent = data.totalOrders;
document.getElementById('orders-change').textContent = formatPercentage(data.ordersChangePercent);
document.getElementById('orders-change').className = getChangeClass(data.ordersChangePercent);
document.getElementById('average-order').textContent = formatCurrency(data.averageOrder);
document.getElementById('average-change').textContent = formatPercentage(data.averageChangePercent);
document.getElementById('average-change').className = getChangeClass(data.averageChangePercent);
document.getElementById('new-customers').textContent = data.newCustomers;
document.getElementById('customers-change').textContent = formatPercentage(data.customersChangePercent);
document.getElementById('customers-change').className = getChangeClass(data.customersChangePercent);
}
// 時間帯別売上チャートの更新
function updateHourlySalesChart(hourlyData) {
const labels = hourlyData.map(item => `${item.hour}:00`);
const salesData = hourlyData.map(item => item.sales);
const ctx = document.getElementById('sales-chart').getContext('2d');
if (hourlyChart) {
hourlyChart.data.labels = labels;
hourlyChart.data.datasets[0].data = salesData;
hourlyChart.update();
} else {
hourlyChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '時間帯別売上',
data: salesData,
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderWidth: 2,
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '¥' + value.toLocaleString();
}
}
}
}
}
});
}
}
// カテゴリ別売上チャートの更新
function updateCategorySalesChart(categoryData) {
const labels = categoryData.map(item => item.name);
const salesData = categoryData.map(item => item.sales);
const ctx = document.getElementById('category-chart').getContext('2d');
if (categoryChart) {
categoryChart.data.labels = labels;
categoryChart.data.datasets[0].data = salesData;
categoryChart.update();
} else {
categoryChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'カテゴリ別売上',
data: salesData,
backgroundColor: [
'rgba(255, 99, 132, 0.7)',
'rgba(54, 162, 235, 0.7)',
'rgba(255, 206, 86, 0.7)',
'rgba(75, 192, 192, 0.7)',
'rgba(153, 102, 255, 0.7)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '¥' + value.toLocaleString();
}
}
}
}
}
});
}
}
// 地域別売上チャートの更新
function updateRegionSalesChart(regionData) {
const labels = regionData.map(item => item.name);
const salesData = regionData.map(item => item.sales);
const ctx = document.getElementById('region-chart').getContext('2d');
if (regionChart) {
regionChart.data.labels = labels;
regionChart.data.datasets[0].data = salesData;
regionChart.update();
} else {
regionChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
label: '地域別売上',
data: salesData,
backgroundColor: [
'rgba(255, 99, 132, 0.7)',
'rgba(54, 162, 235, 0.7)',
'rgba(255, 206, 86, 0.7)',
'rgba(75, 192, 192, 0.7)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false
}
});
}
}
// 最近の注文テーブルの更新
function updateRecentOrdersTable(orders) {
const tableBody = document.getElementById('recent-orders');
tableBody.innerHTML = '';
orders.forEach(order => {
const row = document.createElement('tr');
const idCell = document.createElement('td');
idCell.textContent = order._id;
row.appendChild(idCell);
const customerCell = document.createElement('td');
customerCell.textContent = order.customer_name;
row.appendChild(customerCell);
const productCell = document.createElement('td');
productCell.textContent = order.product;
row.appendChild(productCell);
const amountCell = document.createElement('td');
amountCell.textContent = formatCurrency(order.total_amount);
row.appendChild(amountCell);
const statusCell = document.createElement('td');
statusCell.textContent = order.status;
row.appendChild(statusCell);
tableBody.appendChild(row);
});
}
// ローディング表示
function showLoading(containerId) {
const container = document.getElementById(containerId);
if (container) {
container.classList.add('loading');
}
}
// ローディング非表示
function hideLoading(containerId) {
const container = document.getElementById(containerId);
if (container) {
container.classList.remove('loading');
}
}
// 通貨フォーマット
function formatCurrency(value) {
return '¥' + parseInt(value).toLocaleString();
}
// パーセンテージフォーマット
function formatPercentage(value) {
return (value > 0 ? '+' : '') + value + '%';
}
// 変化率のクラス取得
function getChangeClass(value) {
return value > 0 ? 'positive-change' : (value < 0 ? 'negative-change' : 'no-change');
}
// 日時フォーマット
function formatDateTime(dateTimeStr) {
const date = new Date(dateTimeStr);
return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
// 初期化実行
initialize();
});
カスタムAPI(モックデータ PHP)
以下のPHPをカスタムAPIに設定します。モックデータをJSONで返すだけの最小構成です。
カスタムAPIを作成する際、識別名を「dashbord_mock」に設定してください。
<?php
// SPIRALからリクエストデータを取得
$requestBody = $SPIRAL->getCustomApiRequestBody();
// アクション取得
$action = $requestBody['params']['action'] ?? '';
// レスポンス初期化
$response = [
'status' => 'success',
'data' => [
'success' => true,
'data' => null,
'error' => null
]
];
// アクションに応じたモックデータを返す
switch ($action) {
case 'getSalesSummary':
// 売上サマリーデータ
$response['data']['data'] = [
'result' => [
'totalSales' => 20000,
'salesChangePercent' => 33.3,
'totalOrders' => 2,
'ordersChangePercent' => 100.0,
'averageOrder' => 10000,
'averageChangePercent' => -33.3,
'newCustomers' => 2,
'customersChangePercent' => 100.0
]
];
break;
case 'getHourlySales':
// 時間帯別売上データ
$response['data']['data'] = [
'result' => [
'hourly' => [
['hour' => 0, 'sales' => 1000],
['hour' => 2, 'sales' => 1500],
['hour' => 4, 'sales' => 2000],
['hour' => 6, 'sales' => 2500],
['hour' => 8, 'sales' => 3000],
['hour' => 10, 'sales' => 3500],
['hour' => 12, 'sales' => 4000],
['hour' => 14, 'sales' => 4500],
['hour' => 16, 'sales' => 5000],
['hour' => 18, 'sales' => 5500],
['hour' => 20, 'sales' => 6000],
['hour' => 22, 'sales' => 6500]
]
]
];
break;
case 'getCategorySales':
// カテゴリ別売上データ
$response['data']['data'] = [
'result' => [
'categories' => [
['name' => '家電', 'sales' => 1000],
['name' => '衣類', 'sales' => 1500],
['name' => '書籍', 'sales' => 2000],
['name' => '食品', 'sales' => 2500],
['name' => 'その他', 'sales' => 3000]
]
]
];
break;
case 'getRegionSales':
// 地域別売上データ
$response['data']['data'] = [
'result' => [
'regions' => [
['name' => '関東', 'sales' => 1000],
['name' => '関西', 'sales' => 1500],
['name' => '東北', 'sales' => 2000],
['name' => '近畿', 'sales' => 2500]
]
]
];
break;
case 'getRecentOrders':
// 最近の注文データ
$response['data']['data'] = [
'result' => [
'orders' => [
['_id' => 1, 'customer_name' => '山田太郎', 'product' => 'ノートパソコン', 'total_amount' => 120000, 'status' => '完了'],
['_id' => 2, 'customer_name' => '鈴木花子', 'product' => 'スマートフォン', 'total_amount' => 85000, 'status' => '発送中'],
['_id' => 3, 'customer_name' => '佐藤一郎', 'product' => 'デジタルカメラ', 'total_amount' => 65000, 'status' => '処理中'],
['_id' => 4, 'customer_name' => '田中逸子', 'product' => 'タブレット', 'total_amount' => 45000, 'status' => '完了'],
['_id' => 5, 'customer_name' => '伊藤健太', 'product' => 'ワイヤレスイヤホン', 'total_amount' => 25000, 'status' => '完了']
]
]
];
break;
default:
// 不明なアクション
$response['data']['success'] = false;
$response['data']['error'] = 'Invalid action: ' . $action;
break;
}
// レスポンスをSPIRALに返す
$SPIRAL->setCustomApiResponse($response);
?>
実装のポイント
setIntervalでデータを一定間隔で取得し再描画2. 可視化: Chart.jsを使い折れ線・棒・円グラフを表示
3. 更新間隔変更: ユーザーが更新間隔を動的に指定可能
4. レスポンシブ: グリッドレイアウトで各画面サイズに対応
5. UX向上: ローディング表示・最終更新時刻の明示
本格実装パターン(DB連携)
カスタムAPI(モックデータ)でUIを確認できたら、実際のDBデータを取得するカスタムAPIへ切り替えます。
本格実装パターンを用いる場合は、カスタムAPI用のライブラリを使用する必要があります。
JavaScriptクライアント(api-client.js)をサイトファイルへ、
PHP APIクライアント(api.php)をPHPモジュールへ配置してください。
※ライブラリの詳細についてはカスタムAPI用ライブラリの解説記事をご参照ください。
HTML
以下のコードを認証エリアのページのbodyタブに配置してください。
<div class="dashboard-container">
<div class="dashboard-header">
<h1 class="dashboard-title">リアルタイムデータ可視化ダッシュボード</h1>
<div class="dashboard-controls">
<select id="interval-select" class="dashboard-interval">
<option value="5000">5秒</option>
<option value="10000" selected>10秒</option>
<option value="30000">30秒</option>
<option value="60000">1分</option>
</select>
<button id="refresh-button" class="dashboard-refresh">今すぐ更新</button>
</div>
</div>
<div id="last-updated" class="dashboard-status">最終更新: -</div>
<div class="dashboard-summary">
<div class="summary-card">
<div class="summary-title">総売上</div>
<div id="total-sales" class="summary-value">¥0</div>
<div id="sales-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
<div class="summary-card">
<div class="summary-title">注文数</div>
<div id="total-orders" class="summary-value">0</div>
<div id="orders-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
<div class="summary-card">
<div class="summary-title">平均注文額</div>
<div id="average-order" class="summary-value">¥0</div>
<div id="average-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
<div class="summary-card">
<div class="summary-title">新規顧客</div>
<div id="new-customers" class="summary-value">0</div>
<div id="customers-change" class="summary-change">前日比: <span class="positive-trend">+0%</span></div>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">時間帯別売上</h2>
</div>
<div class="dashboard-chart-container">
<canvas id="sales-chart"></canvas>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">カテゴリ別売上</h2>
</div>
<div class="dashboard-chart-container">
<canvas id="category-chart"></canvas>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">地域別売上</h2>
</div>
<div class="dashboard-chart-container">
<canvas id="region-chart"></canvas>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-header">
<h2 class="dashboard-card-title">最近の注文</h2>
</div>
<table class="dashboard-table">
<thead>
<tr>
<th>注文ID</th>
<th>顧客名</th>
<th>商品</th>
<th>金額</th>
<th>状態</th>
</tr>
</thead>
<tbody id="recent-orders">
<tr>
<td colspan="5">データを読み込み中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="loading" class="dashboard-loading">
<div class="dashboard-spinner"></div>
</div>
<script src="/_media/api-client.js"></script>
<a href="#" data-logout>Logout</a>
JavaScript(DB連携用)
基本構造はモック版と同じです。APIレスポンスに合わせてパース処理やエラーハンドリングを調整してください。
JSタブに配置します。
// APIクライアントのインスタンス
let api;
// チャートのインスタンス
let salesChart;
let categoryChart;
let regionChart;
// 更新間隔(ミリ秒)
let updateInterval = 10000;
// 更新タイマーID
let updateTimerId;
// api-client.jsが読み込まれているか確認する関数
function checkApiClientLoaded() {
if (typeof ApiClient === 'undefined') {
console.error('ApiClientが読み込まれていません。api-client.jsが正しく読み込まれているか確認してください。');
// 500ミリ秒後に再試行
setTimeout(checkApiClientLoaded, 500);
return false;
}
console.log('ApiClientが正常に読み込まれました');
// ApiClientが利用可能になったらインスタンスを作成
api = new ApiClient();
// ダッシュボードの初期化
initializeDashboard();
return true;
}
/**
* ダッシュボードの初期化
*/
function initializeDashboard() {
// DOM要素の参照を取得
const intervalSelect = document.getElementById('interval-select');
const refreshButton = document.getElementById('refresh-button');
const lastUpdated = document.getElementById('last-updated');
const loadingIndicator = document.getElementById('loading');
// チャートの初期化
initializeCharts();
// イベントリスナーを設定
intervalSelect.addEventListener('change', function() {
updateInterval = parseInt(this.value);
restartUpdateTimer();
});
refreshButton.addEventListener('click', function() {
fetchDashboardData();
});
// 初回データ取得
fetchDashboardData();
// 定期的な更新を開始
startUpdateTimer();
}
/**
* チャートの初期化
*/
function initializeCharts() {
// 時間帯別売上チャート
const salesChartCtx = document.getElementById('sales-chart').getContext('2d');
salesChart = new Chart(salesChartCtx, {
type: 'line',
data: {
labels: ['0時', '2時', '4時', '6時', '8時', '10時', '12時', '14時', '16時', '18時', '20時', '22時'],
datasets: [{
label: '売上(円)',
data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
backgroundColor: 'rgba(52, 152, 219, 0.2)',
borderColor: 'rgba(52, 152, 219, 1)',
borderWidth: 2,
pointBackgroundColor: 'rgba(52, 152, 219, 1)',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '¥' + value.toLocaleString();
}
}
}
},
plugins: {
tooltip: {
callbacks: {
label: function(context) {
return '売上: ¥' + context.raw.toLocaleString();
}
}
}
}
}
});
// カテゴリ別売上チャート
const categoryChartCtx = document.getElementById('category-chart').getContext('2d');
categoryChart = new Chart(categoryChartCtx, {
type: 'doughnut',
data: {
labels: ['家電', '食品', '衣類', '家具', 'その他'],
datasets: [{
data: [0, 0, 0, 0, 0],
backgroundColor: [
'rgba(52, 152, 219, 0.7)',
'rgba(46, 204, 113, 0.7)',
'rgba(155, 89, 182, 0.7)',
'rgba(230, 126, 34, 0.7)',
'rgba(149, 165, 166, 0.7)'
],
borderColor: [
'rgba(52, 152, 219, 1)',
'rgba(46, 204, 113, 1)',
'rgba(155, 89, 182, 1)',
'rgba(230, 126, 34, 1)',
'rgba(149, 165, 166, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = total > 0 ? Math.round((value / total) * 100) : 0;
return context.label + ': ¥' + value.toLocaleString() + ' (' + percentage + '%)';
}
}
}
}
}
});
// 地域別売上チャート
const regionChartCtx = document.getElementById('region-chart').getContext('2d');
regionChart = new Chart(regionChartCtx, {
type: 'bar',
data: {
labels: ['北海道', '東北', '関東', '中部', '関西', '中国', '四国', '九州'],
datasets: [{
label: '売上(円)',
data: [0, 0, 0, 0, 0, 0, 0, 0],
backgroundColor: 'rgba(46, 204, 113, 0.7)',
borderColor: 'rgba(46, 204, 113, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '¥' + value.toLocaleString();
}
}
}
},
plugins: {
tooltip: {
callbacks: {
label: function(context) {
return '売上: ¥' + context.raw.toLocaleString();
}
}
}
}
}
});
}
/**
* ダッシュボードデータの取得
*/
async function fetchDashboardData() {
toggleLoading(true);
try {
// 売上サマリーデータの取得
const summaryResult = await api.getDatabaseRecords({
action: 'getSalesSummary',
date: new Date().toISOString().split('T')[0]
});
if (summaryResult.status === "success" && summaryResult.data && summaryResult.data.data && summaryResult.data.data.result) {
updateSummary(summaryResult.data.data.result);
}
// 時間帯別売上データの取得
const hourlyResult = await api.getDatabaseRecords({
action: 'getHourlySales',
date: new Date().toISOString().split('T')[0]
});
if (hourlyResult.status === "success" && hourlyResult.data && hourlyResult.data.data && hourlyResult.data.data.result) {
updateSalesChart(hourlyResult.data.data.result);
}
// カテゴリ別売上データの取得
const categoryResult = await api.getDatabaseRecords({
action: 'getCategorySales',
date: new Date().toISOString().split('T')[0]
});
if (categoryResult.status === "success" && categoryResult.data && categoryResult.data.data && categoryResult.data.data.result) {
updateCategoryChart(categoryResult.data.data.result);
}
// 地域別売上データの取得
const regionResult = await api.getDatabaseRecords({
action: 'getRegionSales',
date: new Date().toISOString().split('T')[0]
});
if (regionResult.status === "success" && regionResult.data && regionResult.data.data && regionResult.data.data.result) {
updateRegionChart(regionResult.data.data.result);
}
// 最近の注文データの取得
const ordersResult = await api.getDatabaseRecords({
action: 'getRecentOrders',
limit: 5
});
if (ordersResult.status === "success" && ordersResult.data && ordersResult.data.data && ordersResult.data.data.result) {
updateRecentOrders(ordersResult.data.data.result);
}
// 最終更新時刻の更新
updateLastUpdated();
} catch (error) {
console.error('データの取得中にエラーが発生しました', error);
}
toggleLoading(false);
}
/**
* 売上サマリーの更新
* @param {Object} data - サマリーデータ
*/
function updateSummary(data) {
// 総売上
const totalSales = document.getElementById('total-sales');
totalSales.textContent = '¥' + (data.totalSales || 0).toLocaleString();
// 売上変化率
const salesChange = document.getElementById('sales-change');
updateChangeDisplay(salesChange, data.salesChangePercent || 0);
// 注文数
const totalOrders = document.getElementById('total-orders');
totalOrders.textContent = (data.totalOrders || 0).toLocaleString();
// 注文数変化率
const ordersChange = document.getElementById('orders-change');
updateChangeDisplay(ordersChange, data.ordersChangePercent || 0);
// 平均注文額
const averageOrder = document.getElementById('average-order');
averageOrder.textContent = '¥' + (data.averageOrder || 0).toLocaleString();
// 平均注文額変化率
const averageChange = document.getElementById('average-change');
updateChangeDisplay(averageChange, data.averageChangePercent || 0);
// 新規顧客数
const newCustomers = document.getElementById('new-customers');
newCustomers.textContent = (data.newCustomers || 0).toLocaleString();
// 新規顧客数変化率
const customersChange = document.getElementById('customers-change');
updateChangeDisplay(customersChange, data.customersChangePercent || 0);
}
/**
* 変化率表示の更新
* @param {HTMLElement} element - 表示要素
* @param {number} changePercent - 変化率(%)
*/
function updateChangeDisplay(element, changePercent) {
const isPositive = changePercent >= 0;
const absChange = Math.abs(changePercent);
const sign = isPositive ? '+' : '-';
const className = isPositive ? 'positive-trend' : 'negative-trend';
element.innerHTML = `前日比: <span class="${className}">${sign}${absChange}%</span>`;
}
/**
* 時間帯別売上チャートの更新
* @param {Object} data - 時間帯別売上データ
*/
function updateSalesChart(data) {
if (!data || !data.hourly || !Array.isArray(data.hourly)) {
return;
}
// データの更新
salesChart.data.datasets[0].data = data.hourly.map(item => item.sales);
// チャートの更新
salesChart.update();
}
/**
* カテゴリ別売上チャートの更新
* @param {Object} data - カテゴリ別売上データ
*/
function updateCategoryChart(data) {
if (!data || !data.categories || !Array.isArray(data.categories)) {
return;
}
// ラベルの更新
categoryChart.data.labels = data.categories.map(item => item.name);
// データの更新
categoryChart.data.datasets[0].data = data.categories.map(item => item.sales);
// チャートの更新
categoryChart.update();
}
/**
* 地域別売上チャートの更新
* @param {Object} data - 地域別売上データ
*/
function updateRegionChart(data) {
if (!data || !data.regions || !Array.isArray(data.regions)) {
return;
}
// ラベルの更新
regionChart.data.labels = data.regions.map(item => item.name);
// データの更新
regionChart.data.datasets[0].data = data.regions.map(item => item.sales);
// チャートの更新
regionChart.update();
}
/**
* 最近の注文リストの更新
* @param {Object} data - 注文データ
*/
function updateRecentOrders(data) {
if (!data || !data.orders || !Array.isArray(data.orders)) {
return;
}
const ordersTableBody = document.getElementById('recent-orders');
// テーブルの内容をクリア
ordersTableBody.innerHTML = '';
// 注文データの追加
data.orders.forEach(order => {
const row = document.createElement('tr');
// 注文ID
const idCell = document.createElement('td');
idCell.textContent = order.id;
row.appendChild(idCell);
// 顧客名
const customerCell = document.createElement('td');
customerCell.textContent = order.customerName;
row.appendChild(customerCell);
// 商品
const productCell = document.createElement('td');
productCell.textContent = order.productName;
row.appendChild(productCell);
// 金額
const amountCell = document.createElement('td');
amountCell.textContent = '¥' + order.amount.toLocaleString();
row.appendChild(amountCell);
// 状態
const statusCell = document.createElement('td');
statusCell.textContent = order.status;
// 状態に応じたスタイルを適用
if (order.status === '完了') {
statusCell.style.color = '#27ae60';
} else if (order.status === '処理中') {
statusCell.style.color = '#f39c12';
} else if (order.status === 'キャンセル') {
statusCell.style.color = '#e74c3c';
}
row.appendChild(statusCell);
ordersTableBody.appendChild(row);
});
}
/**
* 最終更新時刻の更新
*/
function updateLastUpdated() {
const lastUpdated = document.getElementById('last-updated');
const now = new Date();
const timeString = now.toLocaleTimeString();
lastUpdated.textContent = `最終更新: ${timeString}`;
}
/**
* ローディング表示の切り替え
* @param {boolean} isLoading - ローディング中かどうか
*/
function toggleLoading(isLoading) {
const loadingIndicator = document.getElementById('loading');
loadingIndicator.style.display = isLoading ? 'flex' : 'none';
}
/**
* 更新タイマーの開始
*/
function startUpdateTimer() {
updateTimerId = setInterval(fetchDashboardData, updateInterval);
}
/**
* 更新タイマーの停止
*/
function stopUpdateTimer() {
if (updateTimerId) {
clearInterval(updateTimerId);
updateTimerId = null;
}
}
/**
* 更新タイマーの再起動
*/
function restartUpdateTimer() {
stopUpdateTimer();
startUpdateTimer();
}
// DOMが読み込まれたら実行
document.addEventListener('DOMContentLoaded', () => {
// api-client.jsが読み込まれているか確認
checkApiClientLoaded();
});
カスタムAPI/PHP(DB連携用)
以下のPHPをカスタムAPIに登録してください。
<?php
require_once 'api.php'; //PHPモジュールのディレクトリを指定
// APIインスタンス取得
$api = Api::getInstance();
// SPIRALオブジェクトからリクエストデータを取得
$requestBody = $SPIRAL->getCustomApiRequestBody();
$queryParams = $SPIRAL->getCustomApiQueryParameters();
// アクション取得
$action = $requestBody['params']['action'] ?? '';
// パラメータ設定(以下DBのIDを指定してください)
$appId = "";
$dbId = isset($requestBody['dbId']) && $requestBody['dbId'] !== '' ? $requestBody['dbId'] : null; //変動なし
$ordersDbId = "";
$customersDbId ="";
$order_itemsDbId = "";
$productsDbId = "";
// レスポンス初期化
$response = [
'status' => 'success',
'data' => [
'success' => false,
'data' => null,
'error' => null
]
];
try {
// アクションに応じた処理
switch ($action) {
case 'getSalesSummary':
// 売上サマリーデータの取得
// 接続先DB: orders (注文テーブル), customers (顧客テーブル)
$date = isset($requestBody['params']['date']) ? $requestBody['params']['date'] : date('Y-m-d');
$prevDate = date('Y-m-d', strtotime($date . ' -1 day'));
// 本日の売上データを取得
$todayParams = [
'where' => "@order_date >= '" . $date . " 00:00:00' AND @order_date <= '" . $date . " 23:59:59'"
];
$todayResult = $api->getDatabaseRecords($todayParams, $ordersDbId, $appId);
$todayData = $api->getResponseData($todayResult);
// 前日の売上データを取得
$yesterdayParams = [
'where' => "@order_date >= '" . $prevDate . " 00:00:00' AND @order_date <= '" . $prevDate . " 23:59:59'"
];
$yesterdayResult = $api->getDatabaseRecords($yesterdayParams, $ordersDbId, $appId);
$yesterdayData = $api->getResponseData($yesterdayResult);
// 売上データの集計
$todaySales = 0;
$todayOrders = count($todayData['items'] ?? []);
foreach ($todayData['items'] ?? [] as $record) {
$todaySales += floatval($record['total_amount'] ?? 0);
}
$yesterdaySales = 0;
$yesterdayOrders = count($yesterdayData['items'] ?? []);
foreach ($yesterdayData['items'] ?? [] as $record) {
$yesterdaySales += floatval($record['total_amount'] ?? 0);
}
// 平均注文額の計算
$todayAverage = $todayOrders > 0 ? $todaySales / $todayOrders : 0;
$yesterdayAverage = $yesterdayOrders > 0 ? $yesterdaySales / $yesterdayOrders : 0;
// 新規顧客の取得
$newCustomersParams = [
'where' => "@_createdAt >= '" . $date . " 00:00:00' AND @_createdAt <= '" . $date . " 23:59:59'"
];
$newCustomersResult = $api->getDatabaseRecords($newCustomersParams, $customersDbId, $appId);
$newCustomersData = $api->getResponseData($newCustomersResult);
$todayNewCustomers = count($newCustomersData['items'] ?? []);
// 前日の新規顧客
$yesterdayNewCustomersParams = [
'where' => "@_createdAt >= '" . $prevDate . " 00:00:00' AND @_createdAt <= '" . $prevDate . " 23:59:59'"
];
$yesterdayNewCustomersResult = $api->getDatabaseRecords($yesterdayNewCustomersParams, $customersDbId, $appId);
$yesterdayNewCustomersData = $api->getResponseData($yesterdayNewCustomersResult);
$yesterdayNewCustomers = count($yesterdayNewCustomersData['items'] ?? []);
// 変化率の計算
$salesChangePercent = calculateChangePercent($todaySales, $yesterdaySales);
$ordersChangePercent = calculateChangePercent($todayOrders, $yesterdayOrders);
$averageChangePercent = calculateChangePercent($todayAverage, $yesterdayAverage);
$customersChangePercent = calculateChangePercent($todayNewCustomers, $yesterdayNewCustomers);
$summaryData = [
'totalSales' => $todaySales,
'salesChangePercent' => round($salesChangePercent, 1),
'totalOrders' => $todayOrders,
'ordersChangePercent' => round($ordersChangePercent, 1),
'averageOrder' => round($todayAverage),
'averageChangePercent' => round($averageChangePercent, 1),
'newCustomers' => $todayNewCustomers,
'customersChangePercent' => round($customersChangePercent, 1)
];
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => $summaryData,
'requestInfo' => [
'action' => $action,
'date' => $date
]
];
break;
case 'getHourlySales':
// 時間帯別売上データの取得
// 接続先DB: orders (注文テーブル)
$date = isset($requestBody['params']['date']) ? $requestBody['params']['date'] : date('Y-m-d');
// 全ての時間帯の売上を初期化
$hourlyData = [];
for ($i = 0; $i < 24; $i += 2) {
$hourlyData[$i] = 0;
}
// 当日の売上データを取得
$params = [
'where' => "@order_date >= '" . $date . " 00:00:00' AND @order_date <= '" . $date . " 23:59:59'"
];
$result = $api->getDatabaseRecords($params, $ordersDbId, $appId);
$data = $api->getResponseData($result);
// 時間帯別に集計
foreach ($data['items'] ?? [] as $record) {
if (isset($record['order_date'])) {
$orderTime = strtotime($record['order_date']);
$hour = intval(date('G', $orderTime));
// 2時間ごとに集計するため、偶数時間に丸める
$hourRounded = floor($hour / 2) * 2;
$hourlyData[$hourRounded] += floatval($record['total_amount'] ?? 0);
}
}
// 結果の形式を整える
$hourlySales = [];
foreach ($hourlyData as $hour => $sales) {
$hourlySales[] = [
'hour' => $hour,
'sales' => $sales
];
}
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => [
'hourly' => $hourlySales
],
'requestInfo' => [
'action' => 'getHourlySales',
'date' => $date
]
];
break;
case 'getCategorySales':
// カテゴリ別売上データの取得
// 接続先DB: orders (注文テーブル), order_items (注文明細テーブル), products (商品テーブル)
$date = isset($requestBody['params']['date']) ? $requestBody['params']['date'] : date('Y-m-d');
// 注文データを取得
$ordersParams = [
'where' => "@order_date >= '" . $date . " 00:00:00' AND @order_date <= '" . $date . " 23:59:59'"
];
$ordersResult = $api->getDatabaseRecords($ordersParams, $ordersDbId, $appId);
$ordersData = $api->getResponseData($ordersResult);
// 商品テーブルからカテゴリオプションを取得
$productsParams = [
'limit' => 1
];
$productsResult = $api->getDatabaseRecords($productsParams, $productsDbId, $appId);
$productsData = $api->getResponseData($productsResult);
// オプション(IDとラベルのマッピング)を取得
$options = $productsData['options'] ?? [];
$categoryOptions = $options['category'] ?? [];
// 注文IDを抽出
$orderIds = [];
foreach ($ordersData['items'] ?? [] as $record) {
$orderIds[] = $record['_id'];
}
// 注文明細データを取得
$orderItemsParams = [
'where' => "@order_id IN ('" . implode("','", $orderIds) . "')"
];
$orderItemsResult = $api->getDatabaseRecords($orderItemsParams, $order_itemsDbId, $appId);
$orderItemsData = $api->getResponseData($orderItemsResult);
// 参照フィールドのマッピングを作成
$productReferenceMap = []; // 商品IDの参照マッピング
$orderReferenceMap = []; // 注文IDの参照マッピング
// 商品IDを抽出
$productIds = [];
foreach ($orderItemsData['items'] ?? [] as $record) {
// product_idが参照フィールドの場合
if (isset($record['product_id']['url'])) {
$itemId = $record['_id'];
$productReferenceMap[$itemId] = [
'url' => $record['product_id']['url']
];
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $record['product_id']['url'], $matches);
if (isset($matches[1])) {
$productId = $matches[1];
$productIds[] = $productId;
$productReferenceMap[$itemId]['product_id'] = $productId;
}
} else if (isset($record['product_id'])) {
// 直接IDが格納されている場合
$productIds[] = $record['product_id'];
}
// order_idが参照フィールドの場合
if (isset($record['order_id']['url'])) {
$itemId = $record['_id'];
$orderReferenceMap[$itemId] = [
'url' => $record['order_id']['url']
];
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $record['order_id']['url'], $matches);
if (isset($matches[1])) {
$orderId = $matches[1];
$orderReferenceMap[$itemId]['order_id'] = $orderId;
}
}
}
// 商品データを取得
$productNames = [];
$productCategories = [];
// 商品IDがある場合、DBから取得
if (!empty($productIds)) {
$productsParams = [
'where' => "@id IN ('" . implode("','", $productIds) . "')"
];
$productsResult = $api->getDatabaseRecords($productsParams, $productsDbId, $appId);
$productsData = $api->getResponseData($productsResult);
// 商品IDと名前・カテゴリのマッピングを作成
foreach ($productsData['items'] ?? [] as $record) {
$productNames[$record['_id']] = $record['name'];
$productCategories[$record['_id']] = $record['category'];
}
}
// 参照URLから商品情報を直接取得
foreach ($productReferenceMap as $itemId => $reference) {
if (isset($reference['url']) && $reference['url'] !== null && isset($reference['product_id'])) {
$productId = $reference['product_id'];
// すでに名前が取得できていない場合は、URLから直接データを取得
if (!isset($productNames[$productId])) {
// URLからホスト部分とパスを抽出
$urlParts = parse_url($reference['url']);
if (isset($urlParts['path']) && isset($urlParts['query'])) {
// パスとクエリを結合して相対URLを作成
$relativePath = $urlParts['path'] . '?' . $urlParts['query'];
// APIのベースURLを除去して相対パスにする
$relativePath = str_replace('https://api.spiral-platform.com/v1/', '', $reference['url']);
// 直接URLにGETリクエストを送信
$refResult = $api->get($relativePath);
$refData = $api->getResponseData($refResult);
// 商品情報を取得
if (isset($refData['items']) && count($refData['items']) > 0) {
$productRecord = $refData['items'][0];
// 商品名を取得
if (isset($productRecord['name'])) {
$productNames[$productId] = $productRecord['name'];
}
// カテゴリを取得
if (isset($productRecord['category'])) {
$productCategories[$productId] = $productRecord['category'];
}
}
}
}
}
}
// 注文と注文明細を結合
$orderItems = [];
foreach ($orderItemsData['items'] ?? [] as $item) {
$itemId = $item['_id'];
// order_idが参照フィールドの場合
if (isset($item['order_id']['url'])) {
if (isset($orderReferenceMap[$itemId]['order_id'])) {
$orderId = $orderReferenceMap[$itemId]['order_id'];
} else {
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $item['order_id']['url'], $matches);
$orderId = isset($matches[1]) ? $matches[1] : null;
}
} else {
// 直接IDが格納されている場合
$orderId = $item['order_id'];
}
// product_idが参照フィールドの場合
if (isset($item['product_id']['url'])) {
if (isset($productReferenceMap[$itemId]['product_id'])) {
$item['product_id'] = $productReferenceMap[$itemId]['product_id'];
} else {
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $item['product_id']['url'], $matches);
$item['product_id'] = isset($matches[1]) ? $matches[1] : null;
}
}
// order_idが文字列でない場合は文字列に変換
if ($orderId !== null) {
if (!is_string($orderId) && !is_int($orderId)) {
$orderId = (string)$orderId;
}
if (!isset($orderItems[$orderId])) {
$orderItems[$orderId] = [];
}
$orderItems[$orderId][] = $item;
}
}
// カテゴリ別売上を集計
$categorySalesData = [];
foreach ($orderItemsData['items'] ?? [] as $record) {
// product_idが参照フィールドの場合
if (isset($record['product_id']['url'])) {
$itemId = $record['_id'];
// 参照マップから商品IDを取得
if (isset($productReferenceMap[$itemId]['product_id'])) {
$productId = $productReferenceMap[$itemId]['product_id'];
} else {
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $record['product_id']['url'], $matches);
$productId = isset($matches[1]) ? $matches[1] : null;
}
} else {
// 直接IDが格納されている場合
$productId = $record['product_id'];
}
// 商品IDが取得できた場合のみ処理
if ($productId !== null) {
// 商品IDが文字列でない場合は文字列に変換
if (!is_string($productId) && !is_int($productId)) {
$productId = (string)$productId;
}
$category = $productCategories[$productId] ?? 'その他';
$subtotal = floatval($record['subtotal'] ?? 0);
if (!isset($categorySalesData[$category])) {
$categorySalesData[$category] = 0;
}
$categorySalesData[$category] += $subtotal;
}
}
// 結果の形式を整える
$categorySales = [];
foreach ($categorySalesData as $category => $sales) {
// カテゴリIDがある場合はラベルに変換
$categoryLabel = $categoryOptions[$category] ?? $category;
$categorySales[] = [
'name' => $categoryLabel,
'sales' => $sales
];
}
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => [
'categories' => $categorySales
],
'requestInfo' => [
'action' => 'getCategorySales',
'date' => $date
]
];
break;
case 'getRegionSales':
// 地域別売上データの取得
// 接続先DB: orders (注文テーブル)
$date = isset($requestBody['params']['date']) ? $requestBody['params']['date'] : date('Y-m-d');
// 本日の売上データを取得
$params = [
'where' => "@order_date >= '" . $date . " 00:00:00' AND @order_date <= '" . $date . " 23:59:59'"
];
$result = $api->getDatabaseRecords($params, $ordersDbId, $appId);
$data = $api->getResponseData($result);
// オプション(IDとラベルのマッピング)を取得
$options = $data['options'] ?? [];
$regionOptions = $options['region'] ?? [];
// 地域別売上を集計
$regionSalesData = [];
foreach ($data['items'] ?? [] as $record) {
$region = $record['region'] ?? 'その他';
$amount = floatval($record['total_amount'] ?? 0);
if (!isset($regionSalesData[$region])) {
$regionSalesData[$region] = 0;
}
$regionSalesData[$region] += $amount;
}
// 結果の形式を整える
$regionSales = [];
foreach ($regionSalesData as $region => $sales) {
// 地域IDがある場合はラベルに変換
$regionLabel = $regionOptions[$region] ?? $region;
$regionSales[] = [
'name' => $regionLabel,
'sales' => $sales
];
}
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => [
'regions' => $regionSales
],
'requestInfo' => [
'action' => 'getRegionSales',
'date' => $date
]
];
break;
case 'getRecentOrders':
// 最近の注文データの取得
// 接続先DB: orders (注文テーブル), order_items (注文明細テーブル), products (商品テーブル), customers (顧客テーブル)
$limit = isset($requestBody['params']['limit']) ? intval($requestBody['params']['limit']) : 10;
// 注文データを取得
$ordersParams = [
'where' => "@order_date IS NOT NULL",
'limit' => $limit,
'sort' => [
['field' => 'order_date', 'order' => 'desc']
]
];
$ordersResult = $api->getDatabaseRecords($ordersParams, $ordersDbId, $appId);
$ordersData = $api->getResponseData($ordersResult);
// オプション(IDとラベルのマッピング)を取得
$options = $ordersData['options'] ?? [];
$regionOptions = $options['region'] ?? [];
$paymentMethodOptions = $options['payment_method'] ?? [];
$statusOptions = $options['status'] ?? [];
// 顧客IDを抽出
$customerIds = [];
$customerReferenceMap = []; // URLと顧客IDのマッピング
foreach ($ordersData['items'] ?? [] as $record) {
// 参照フィールドの場合、URLから参照先情報を取得する必要がある
if (isset($record['customer_id']['url'])) {
$customerReferenceMap[$record['_id']] = [
'url' => $record['customer_id']['url']
];
} else if (isset($record['customer_id'])) {
// 直接IDが格納されている場合
$customerIds[] = $record['customer_id'];
$customerReferenceMap[$record['_id']] = [
'customer_id' => $record['customer_id'],
'url' => null
];
}
}
// 顧客データを取得(IDがある場合)
$customerNames = [];
if (!empty($customerIds)) {
$customersParams = [
'where' => "@id IN ('" . implode("','", $customerIds) . "')"
];
$customersResult = $api->getDatabaseRecords($customersParams, $customersDbId, $appId);
$customersData = $api->getResponseData($customersResult);
// 顧客IDと名前のマッピングを作成
foreach ($customersData['items'] ?? [] as $record) {
$customerNames[$record['_id']] = $record['name'];
}
}
// 参照URLから顧客情報を直接取得
foreach ($customerReferenceMap as $orderId => $reference) {
// URLがある場合は直接そのURLにGETリクエストを送信
if (isset($reference['url']) && $reference['url'] !== null) {
// URLからホスト部分とパスを抽出
$urlParts = parse_url($reference['url']);
if (isset($urlParts['path']) && isset($urlParts['query'])) {
// パスとクエリを結合して相対URLを作成
$relativePath = $urlParts['path'] . '?' . $urlParts['query'];
// APIのベースURLを除去して相対パスにする
$relativePath = str_replace('https://api.spiral-platform.com/v1/', '', $reference['url']);
// 直接URLにGETリクエストを送信
$refResult = $api->get($relativePath);
$refData = $api->getResponseData($refResult);
// 顧客情報を取得 (APIレスポンスでは'items'キーにデータが格納されている)
if (isset($refData['items']) && count($refData['items']) > 0) {
$customerRecord = $refData['items'][0];
// 顧客IDを取得 ('_id'を使用)
$customerId = $customerRecord['_id'];
// 顧客名を取得
if (isset($customerRecord['name'])) {
$customerNames[$customerId] = $customerRecord['name'];
} else {
$customerNames[$customerId] = '不明(ID: ' . $customerId . ')';
}
// 顧客IDをマッピングに追加
$customerReferenceMap[$orderId]['customer_id'] = $customerId;
}
}
}
}
// 注文IDを抽出
$orderIds = [];
foreach ($ordersData['items'] ?? [] as $record) {
$orderIds[] = $record['_id'];
}
// 注文明細データを取得
$orderItemsParams = [
'where' => "@order_id IN ('" . implode("','", $orderIds) . "')"
];
$orderItemsResult = $api->getDatabaseRecords($orderItemsParams, $order_itemsDbId, $appId);
$orderItemsData = $api->getResponseData($orderItemsResult);
// 参照フィールドのマッピングを作成
$productReferenceMap = []; // 商品IDの参照マッピング
$orderReferenceMap = []; // 注文IDの参照マッピング
// 商品IDを抽出
$productIds = [];
foreach ($orderItemsData['items'] ?? [] as $record) {
// product_idが参照フィールドの場合
if (isset($record['product_id']['url'])) {
$itemId = $record['_id'];
$productReferenceMap[$itemId] = [
'url' => $record['product_id']['url']
];
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $record['product_id']['url'], $matches);
if (isset($matches[1])) {
$productId = $matches[1];
$productIds[] = $productId;
$productReferenceMap[$itemId]['product_id'] = $productId;
}
} else if (isset($record['product_id'])) {
// 直接IDが格納されている場合
$productIds[] = $record['product_id'];
}
// order_idが参照フィールドの場合
if (isset($record['order_id']['url'])) {
$itemId = $record['_id'];
$orderReferenceMap[$itemId] = [
'url' => $record['order_id']['url']
];
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $record['order_id']['url'], $matches);
if (isset($matches[1])) {
$orderId = $matches[1];
$orderReferenceMap[$itemId]['order_id'] = $orderId;
}
}
}
// 商品データを取得
$productNames = [];
$productCategories = [];
// 商品IDがある場合、DBから取得
if (!empty($productIds)) {
$productsParams = [
'where' => "@id IN ('" . implode("','", $productIds) . "')"
];
$productsResult = $api->getDatabaseRecords($productsParams, $productsDbId, $appId);
$productsData = $api->getResponseData($productsResult);
// 商品IDと名前・カテゴリのマッピングを作成
foreach ($productsData['items'] ?? [] as $record) {
$productNames[$record['_id']] = $record['name'];
$productCategories[$record['_id']] = $record['category'];
}
}
// 参照URLから商品情報を直接取得
foreach ($productReferenceMap as $itemId => $reference) {
if (isset($reference['url']) && $reference['url'] !== null && isset($reference['product_id'])) {
$productId = $reference['product_id'];
// すでに名前が取得できていない場合は、URLから直接データを取得
if (!isset($productNames[$productId])) {
// URLからホスト部分とパスを抽出
$urlParts = parse_url($reference['url']);
if (isset($urlParts['path']) && isset($urlParts['query'])) {
// パスとクエリを結合して相対URLを作成
$relativePath = $urlParts['path'] . '?' . $urlParts['query'];
// APIのベースURLを除去して相対パスにする
$relativePath = str_replace('https://api.spiral-platform.com/v1/', '', $reference['url']);
// 直接URLにGETリクエストを送信
$refResult = $api->get($relativePath);
$refData = $api->getResponseData($refResult);
// 商品情報を取得
if (isset($refData['items']) && count($refData['items']) > 0) {
$productRecord = $refData['items'][0];
// 商品名を取得
if (isset($productRecord['name'])) {
$productNames[$productId] = $productRecord['name'];
}
// カテゴリを取得
if (isset($productRecord['category'])) {
$productCategories[$productId] = $productRecord['category'];
}
}
}
}
}
}
// 注文と注文明細を結合
$orderItems = [];
foreach ($orderItemsData['items'] ?? [] as $item) {
$itemId = $item['_id'];
// order_idが参照フィールドの場合
if (isset($item['order_id']['url'])) {
if (isset($orderReferenceMap[$itemId]['order_id'])) {
$orderId = $orderReferenceMap[$itemId]['order_id'];
} else {
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $item['order_id']['url'], $matches);
$orderId = isset($matches[1]) ? $matches[1] : null;
}
} else {
// 直接IDが格納されている場合
$orderId = $item['order_id'];
}
// product_idが参照フィールドの場合
if (isset($item['product_id']['url'])) {
if (isset($productReferenceMap[$itemId]['product_id'])) {
$item['product_id'] = $productReferenceMap[$itemId]['product_id'];
} else {
// URLから参照先のIDを抽出
preg_match('/referrer=.*?:(\d+)/', $item['product_id']['url'], $matches);
$item['product_id'] = isset($matches[1]) ? $matches[1] : null;
}
}
// order_idが文字列でない場合は文字列に変換
if ($orderId !== null) {
if (!is_string($orderId) && !is_int($orderId)) {
$orderId = (string)$orderId;
}
if (!isset($orderItems[$orderId])) {
$orderItems[$orderId] = [];
}
$orderItems[$orderId][] = $item;
}
}
// 結果の形式を整える
$recentOrders = [];
foreach ($ordersData['items'] ?? [] as $record) {
$orderId = $record['_id'];
// customer_idが参照フィールドの場合の処理
if (isset($record['customer_id']['url'])) {
// 参照マップから顧客IDを取得
$customerId = $customerReferenceMap[$orderId]['customer_id'] ?? null;
// 顧客IDが取得できない場合は参照URLから抽出
if ($customerId === null) {
preg_match('/referrer=.*?:(\d+)/', $record['customer_id']['url'], $matches);
$customerId = isset($matches[1]) ? $matches[1] : null;
}
} else {
// 直接IDが格納されている場合
$customerId = $record['customer_id'];
}
// 顧客IDが文字列でない場合は文字列に変換
if ($customerId !== null && !is_string($customerId) && !is_int($customerId)) {
$customerId = (string)$customerId;
}
// 最初の商品名を取得(複数ある場合は最初のみ表示)
$productName = '';
// $orderIdが文字列でない場合は文字列に変換
$orderIdKey = $orderId;
if (!is_string($orderIdKey) && !is_int($orderIdKey)) {
$orderIdKey = (string)$orderIdKey;
}
if (isset($orderItems[$orderIdKey]) && count($orderItems[$orderIdKey]) > 0) {
$firstItem = $orderItems[$orderIdKey][0];
$productId = $firstItem['product_id'];
$productName = $productNames[$productId] ?? '';
// 複数商品がある場合は「他」を追加
if (count($orderItems[$orderIdKey]) > 1) {
$productName .= ' 他';
}
}
$recentOrders[] = [
'id' => $orderId,
'customerName' => ($customerId !== null) ? ($customerNames[$customerId] ?? '不明') : '不明',
'productName' => $productName,
'amount' => floatval($record['total_amount'] ?? 0),
'status' => $statusOptions[$record['status']] ?? $record['status'] ?? '不明',
'region' => $regionOptions[$record['region']] ?? $record['region'] ?? '不明',
'paymentMethod' => $paymentMethodOptions[$record['payment_method']] ?? $record['payment_method'] ?? '不明'
];
}
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => [
'orders' => $recentOrders
],
'requestInfo' => [
'action' => 'getRecentOrders',
'limit' => $limit
]
];
break;
case 'getDatabases':
// 接続先DB: なし(システム情報の取得)
$params = $requestBody['params'] ?? [];
$result = $api->getDatabases($params, $appId);
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => $api->getResponseData($result),
'requestInfo' => [
'action' => 'getDatabases',
'appId' => $appId,
'params' => $params
]
];
break;
case 'getDatabaseRecords':
// 接続先DB: リクエストで指定されたDB($dbId)
$params = $requestBody['params'] ?? [];
$result = $api->getDatabaseRecords($params, $dbId, $appId);
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => $api->getResponseData($result),
'requestInfo' => [
'action' => 'getDatabaseRecords',
'appId' => $appId,
'dbId' => $dbId,
'params' => $params
]
];
break;
case 'createDatabaseRecord':
// 接続先DB: リクエストで指定されたDB($dbId)
$recordData = $requestBody['recordData'] ?? [];
$result = $api->createDatabaseRecord($recordData, $dbId, $appId);
$response['data']['success'] = true;
$response['data']['data'] = [
'result' => $api->getResponseData($result),
'requestInfo' => [
'action' => 'createDatabaseRecord',
'appId' => $appId,
'dbId' => $dbId,
'recordData' => $recordData
]
];
break;
default:
throw new Exception('不明なアクション: ' . $action);
}
} catch (Exception $e) {
$response['data']['error'] = ['message' => $e->getMessage()];
}
// レスポンス設定
$SPIRAL->setCustomApiResponse($response);
/**
* 変化率を計算する関数
* @param float $current 現在の値
* @param float $previous 以前の値
* @return float 変化率(%)
*/
function calculateChangePercent($current, $previous) {
// 前日が0で今日が0より大きい場合は「新規発生」として100%を返す
if ($previous == 0) {
return $current > 0 ? 100 : 0;
}
// 前日が0より大きく今日が0の場合は「消失」として-100%を返す
if ($current == 0) {
return -100;
}
// 通常の変化率計算
return (($current - $previous) / $previous) * 100;
}
?>
売上データDB構成例
サンプルで使用するテーブル構成は次のとおりです。
| フィールド名 | 識別名 | フィールドタイプ |
|---|---|---|
| 顧客 ID | customer_id | 参照フィールド(顧客テーブル外部キー) |
| 注文日時 | order_date | 日時フィールド |
| 注文合計金額(税込) | total_amount | 数値フィールド |
| 注文状態 | status | セレクトフィールド |
| 支払方法 | payment_method | セレクトフィールド |
| 配送地域 | region | セレクトフィールド |
2. 注文明細テーブル(order_items)
| フィールド名 | 識別名 | フィールドタイプ |
|---|---|---|
| 注文 ID | order_id | 参照フィールド(注文テーブル外部キー) |
| 商品 ID | product_id | 参照フィールド(商品テーブル外部キー) |
| 数量 | quantity | 整数フィールド |
| 単価(税抜) | unit_price | 数値フィールド |
| 税率 | tax_rate | 数値フィールド |
| 小計(税込) | subtotal | 数値フィールド |
3. 商品テーブル(products)
| フィールド名 | 識別名 | フィールドタイプ |
|---|---|---|
| 商品名 | name | テキストフィールド |
| カテゴリ | category | セレクトフィールド |
| 販売価格(税抜) | price | 数値フィールド |
| 商品説明 | description | テキストエリアフィールド |
| 商品画像 URL | image_url | テキストフィールド |
| 在庫数 | stock | 整数フィールド |
4. 顧客テーブル(customers)
| フィールド名 | 識別名 | フィールドタイプ |
|---|---|---|
| 顧客名 | name | テキストフィールド |
| メールアドレス | メールアドレスフィールド | |
| 電話番号 | phone | テキストフィールド |
| 住所 | address | テキストフィールド |
| 地域 | region | テキストフィールド |
| 最終注文日 | last_order_date | 日時フィールド |
まとめ
本記事ではカスタムAPI(モックデータ)→実DB連携の2段階でダッシュボードを構築する手順を解説しました。
カスタムAPI(モックデータ)でUIを短期間で検証し、その後に本番データへ移行することで、開発効率と品質を両立できます。ぜひご活用ください。
発展的な機能拡張
以下の機能を追加することで、さらに実用的なダッシュボードへ発展させられます。
2. ダッシュボードレイアウト変更: ユーザー自身が表示項目をカスタマイズ
3. アラート: 売上急変時に通知を表示
4. エクスポート: CSV/PDFなどへのデータ書き出し
5. 予測分析: 過去データを用いた売上予測の表示
カスタムAPIクライアントを活用すれば、これらの拡張も容易に実装可能です。ぜひプロジェクトに応じてカスタマイズしてみてください。
