開発情報・ナレッジ

投稿者: ShiningStar株式会社 2025年6月3日 (火)

カスタムAPIを使ったグラフ付きダッシュボードを作成するサンプルプログラム

カスタムAPIを使ってリアルタイム売上ダッシュボードを作成するサンプルプログラムを紹介します。
本記事は「最小構成で動作検証 → 実DB連携」の2段階で解説します。
開発初期はカスタムAPI(モックデータ)でUIと動作をすばやく検証し、
その後に本番データへ差し替えることで開発スピードと品質の両立を図れます。

なお、この記事のコードは カスタムAPI用ライブラリ を利用しています。セットアップ手順はリンク先をご確認ください。

簡易実装パターン(モックデータ)

まずは実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);
?>
実装のポイント
1. 定期更新: 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構成例

サンプルで使用するテーブル構成は次のとおりです。

1. 注文テーブル(orders)
フィールド名識別名フィールドタイプ
顧客 IDcustomer_id参照フィールド(顧客テーブル外部キー)
注文日時order_date日時フィールド
注文合計金額(税込)total_amount数値フィールド
注文状態statusセレクトフィールド
支払方法payment_methodセレクトフィールド
配送地域regionセレクトフィールド

2. 注文明細テーブル(order_items)
フィールド名識別名フィールドタイプ
注文 IDorder_id参照フィールド(注文テーブル外部キー)
商品 IDproduct_id参照フィールド(商品テーブル外部キー)
数量quantity整数フィールド
単価(税抜)unit_price数値フィールド
税率tax_rate数値フィールド
小計(税込)subtotal数値フィールド

3. 商品テーブル(products)
フィールド名識別名フィールドタイプ
商品名nameテキストフィールド
カテゴリcategoryセレクトフィールド
販売価格(税抜)price数値フィールド
商品説明descriptionテキストエリアフィールド
商品画像 URLimage_urlテキストフィールド
在庫数stock整数フィールド

4. 顧客テーブル(customers)
フィールド名識別名フィールドタイプ
顧客名nameテキストフィールド
メールアドレスemailメールアドレスフィールド
電話番号phoneテキストフィールド
住所addressテキストフィールド
地域regionテキストフィールド
最終注文日last_order_date日時フィールド

まとめ

本記事ではカスタムAPI(モックデータ)→実DB連携の2段階でダッシュボードを構築する手順を解説しました。
カスタムAPI(モックデータ)でUIを短期間で検証し、その後に本番データへ移行することで、開発効率と品質を両立できます。ぜひご活用ください。

発展的な機能拡張

以下の機能を追加することで、さらに実用的なダッシュボードへ発展させられます。

1. データフィルタリング: 日付範囲や条件指定での絞り込み
2. ダッシュボードレイアウト変更: ユーザー自身が表示項目をカスタマイズ
3. アラート: 売上急変時に通知を表示
4. エクスポート: CSV/PDFなどへのデータ書き出し
5. 予測分析: 過去データを用いた売上予測の表示

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

解決しない場合はこちら コンテンツに関しての
要望はこちら