カスタム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クライアントを活用すれば、これらの拡張も容易に実装可能です。ぜひプロジェクトに応じてカスタマイズしてみてください。