見出し画像

レコード一覧を分類項目でグループ化し明細をテーブル形式で表示する

Kintoneのカスタムビュー機能の活用例です。
今回は、レコード一覧を分類別にグループ化して明細をテーブル形式で表示する方法をご紹介します。

やりたいこと

Kintoneの商品マスタ等で、レコード一覧を分類でグループ化してグループ名の下に明細行をテーブル形式で表示したい。
kintoneの一覧表には絞り込みやクロス集計の機能はありますが、任意のキーでグループ化して表示する機能はありませんので、これをカスタムビューとJavascriptカスタマイズの組み合わせで実現します。
更に、レコード一括取得の制限値(500件)以上のレコードも処理出来る様にパラメータoffset方式で全レコードを取得する様にします。

分類項目でグループ化したカスタムビューのイメージ

サンプルとして、商品マスタのアプリを用いてカスタマイズ前後のイメージを掲載します。

カスタマイズ前(標準のレコード一覧)

標準のレコード一覧

カスタマイズ後(カスタムビュー導入後)

カスタムビューのレコード一覧

カスタムビューの設定

アプリの設定>一覧から、+ボタンで一覧を追加画面を開きます。

一覧表設定画面

一覧追加画面で各設定を以下の通り行います。
一覧名は、自由に好きな名前を付けてもかまいません。
一覧ID(自動採番)は、Javascriptの設定で使用します。

HTMLの欄に以下のHTMLコードをコピーして貼り付けます。
先頭の<META>タグは、レスポンシブデザインを実現する基本設定です。
CSSのデザイン部分は、好きな様に変更して下さい。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>分類別商品一覧表示</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            line-height: 1.6;
            color: #333;
            margin: 0;
            padding: 20px;
        }
        #category-list {
            max-width: 800px;
            margin: 0 auto;
        }
        .category-group {
            margin-bottom: 20px;
            border: 1px solid #ccc;
            border-radius: 5px;
            padding: 15px;
            background-color: #f9f9f9;
        }
        .category-name {
            font-size: 20px;
            font-weight: bold;
            margin-bottom: 10px;
            color: #2c3e50;
            border-bottom: 2px solid #3498db;
            padding-bottom: 5px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin-bottom: 10px;
        }
        th, td {
            border: 1px solid #ccc;
            padding: 8px;
            text-align: left;
        }
        th {
            background-color: #f2f2f2;
        }
        tr:nth-child(odd) {
            background-color: #ffffff;
        }
        tr:nth-child(even) {
            background-color: #e0f7fa;
        }
        .total-count {
            font-weight: normal;
            color: #555;
            font-size: 16px;
        }
    </style>
</head>
<body>
    <div id="category-list"></div>
</body>
</html>

サンプルJavascriptコード

/* レコード一覧を分類項目でグループ化し明細をテーブル形式で表示する
 * Sample Program
 * Distributor: https://note.com/appgroup
 * Copyright (c) 2024 Application Utilization Study Group
 * Licensed under the MIT License
 ------------------------------------------------------------*/
(() => {
    'use strict';

    // 初期設定部分
    const CUSTOM_VIEW_ID = 123456; // 実際のカスタムビューの一覧IDに変更してください
    const KEY_FIELD = '分類';       // グループ化のキー項目
    const REF_FIELD = '商品コード'; // レコード詳細画面とリンクする項目

    // 明細表示項目 name:フィールドコード名、type:フィールの型(STR:文字型、NUM:数値型)
    const FIELDS = {
        ITEM01: { name: '商品名', type: 'STR' },
        ITEM02: { name: '単価'  , type: 'NUM' },
        ITEM03: { name: '在庫数', type: 'NUM' },
        ITEM04: { name: '備考',   type: 'STR' }
    };

    const RECORDS_PER_REQUEST = 500; // 1回の処理で読み込むレコード数
    //---- <初期設定はここまで> ----

    // 数値を3桁カンマ区切りにフォーマットする関数
    const formatNumber = (number) => number.toLocaleString();

    // HTMLエレメントを作成する関数
    const createElement = (tag, className, textContent = '') => {
        const element = document.createElement(tag);
        element.className = className;
        element.textContent = textContent;
        return element;
    };

    // テーブル行を作成する関数
    const createTableRow = (item, appId) => {
        const row = createElement('tr', '');
        row.innerHTML = `
            <td><a href="/k/${appId}/show#record=${item.$id.value}" target="_blank" class="item-link">${item[REF_FIELD].value}</a></td>
            <td>${FIELDS.ITEM01.type === 'NUM' ? formatNumber(Number(item[FIELDS.ITEM01.name].value)) : item[FIELDS.ITEM01.name].value}</td>
            <td>${FIELDS.ITEM02.type === 'NUM' ? formatNumber(Number(item[FIELDS.ITEM02.name].value)) : item[FIELDS.ITEM02.name].value}</td>
            <td>${FIELDS.ITEM03.type === 'NUM' ? formatNumber(Number(item[FIELDS.ITEM03.name].value)) : item[FIELDS.ITEM03.name].value}</td>
            <td>${FIELDS.ITEM04.type === 'NUM' ? formatNumber(Number(item[FIELDS.ITEM04.name].value)) : item[FIELDS.ITEM04.name].value}</td>
        `;
        return row;
    };

    // 取得開始位置:offset(デフォルト値は0)を使用してレコードを取得する関数
    const fetchRecords = async (app, query, offset = 0) => {
        const params = {
            app: app,
            query: query + ` limit ${RECORDS_PER_REQUEST} offset ${offset}`,
        };
        return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', params);
    };

    // 全レコードの集計とグループ化
    const processRecords = (records) => {
        const groupedRecords = {};
        records.forEach(record => {
            const category = record[KEY_FIELD].value;
            if (!groupedRecords[category]) {
                groupedRecords[category] = { records: [], count: 0 };
            }
            groupedRecords[category].records.push(record);
            groupedRecords[category].count += 1;
        });
        return groupedRecords;
    };

    // カテゴリごとの商品テーブルを作成する関数
    const createCategoryTable = (records, appId) => {
        const table = createElement('table', '');
        table.innerHTML = `
            <thead>
                <tr>
                    <th>${REF_FIELD}</th>
                    <th>${FIELDS.ITEM01.name}</th>
                    <th>${FIELDS.ITEM02.name}</th>
                    <th>${FIELDS.ITEM03.name}</th>
                    <th>${FIELDS.ITEM04.name}</th>
                </tr>
            </thead>
            <tbody>
            </tbody>
        `;
        const tbody = table.querySelector('tbody');
        records.sort((a, b) => a[REF_FIELD].value.localeCompare(b[REF_FIELD].value));
        records.forEach(item => {
            tbody.appendChild(createTableRow(item, appId));
        });
        return table;
    };

    // カテゴリ一覧を表示する関数
    const renderCategoryList = (categoryList, groupedRecords, appId) => {
        const sortedCategories = Object.entries(groupedRecords)
            .sort(([, a], [, b]) => b.count - a.count);

        sortedCategories.forEach(([category, { records, count }]) => {
            const categoryGroup = createElement('div', 'category-group');
            const categoryNameElement = createElement('div', 'category-name');
            categoryNameElement.innerHTML = `${category} <span class="total-count">件数: ${count}</span>`;
            categoryGroup.appendChild(categoryNameElement);

            const table = createCategoryTable(records, appId);
            categoryGroup.appendChild(table);

            categoryList.appendChild(categoryGroup);
        });
    };

    // 全レコードを取得する関数
    const getAllRecords = async (appId) => {
        let offset = 0;
        let allRecords = [];
        try {
            while (true) {
                const response = await fetchRecords(appId, '', offset);
                allRecords = allRecords.concat(response.records);
                if (response.records.length < RECORDS_PER_REQUEST) break;
                offset += RECORDS_PER_REQUEST;
            }
        } catch (error) {
            console.error('Error fetching records:', error);
        }
        return allRecords;
    };

    // 一覧表表示イベントで動作
    kintone.events.on('app.record.index.show', async (event) => {
        // 現在の一覧IDがカスタムビューの一覧IDと一致しない場合は何もしない
        if (event.viewId !== CUSTOM_VIEW_ID) return;

        const categoryList = document.getElementById('category-list');
        categoryList.innerHTML = '';

        const appId = kintone.app.getId();
        const allRecords = await getAllRecords(appId);

        const groupedRecords = processRecords(allRecords);
        renderCategoryList(categoryList, groupedRecords, appId);
    });
})();

// 初期設定の部分をアプリの設定に合わせて変更します。
CUSTOM_VIEW_IDには、カスタムビューの「一覧ID」を設定します。
KEY_FIELD には、グループ化のキー項目のフィールドコードを設定します。
REF_FIELD には、レコード詳細画面とリンクする項目のフィールドコードを設定します。
FIELDS
オブジェクトには、フォームのフィールドコードを設定します。
nameプロパティにフィールドコード名を設定します。
typeプロパティにフィールドの型が数値型の場合は'num'を指定し、数値型以外は'str'を設定します。
数値型のフィールドコードは3桁カンマ区切りで表示されます。
初期設定のフィールドコード名をアプリのフォーム設定と合わせれば、様々なアプリに実装することが出来ると思います。

以下の図の様に、設定されたフィールドコードでグループ化の表示を行いますので、フィールドコードはユーザーの言語(日本語)で分かり易い名前に設定するのが推奨です。

初期設定のフィールドのイメージ

カスタマイズした感想

初期設定項目で汎用性を持たせました

サンプルコードは、CUSTOM_VIEW_IDにカスタムビューの「一覧ID」を設定し、アプリのフォーム設定画面のフィールドコードとJavascriptの初期設定のフィールドコードを合わせるだけで動作する様に工夫していますので、色々なアプリに応用することが可能です。
※但し、明細表示項目(FIELDオブジェクト)を5つ以上に増やしたい場合は、FIELD.ITEM01~04を用いている部分に追加修正が必要です。

折りたたみ表示とページネーション機能(参考)

分類の折りたたみ表示とページング機能を追加することも可能です。
グループの種類が多くても折りたたみ表示で見易くなります。
ページング機能を実装すれば、大量のレコード処理でもパフォーマンスが向上し、ユーザーが必要な情報を効率的に閲覧できるようになります。
(サンプルコードでは未実装です)

レコード取得数の制限

レコード一覧での集計処理では、レコード一括取得の制限値(上限500件)も意識してカスタマイズ対応する必要があります。
今回のサンプルコードでは、500件以上のレコードも処理出来る様に以下のコード例の通りパラメータoffset方式で全レコードを取得しています。

    // 取得開始位置:offset(デフォルト値は0)を使用してレコードを取得する関数
    const fetchRecords = async (app, query, offset = 0) => {
        const params = {
            app: app,
            query: query + ` limit ${RECORDS_PER_REQUEST} offset ${offset}`,
        };
        return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', params);
    };

    // 全レコードを取得する関数
    const getAllRecords = async (appId) => {
        let offset = 0;
        let allRecords = [];
        try {
            while (true) {
                const response = await fetchRecords(appId, '', offset);
                allRecords = allRecords.concat(response.records);
                if (response.records.length < RECORDS_PER_REQUEST) break;
                offset += RECORDS_PER_REQUEST;
            }
        } catch (error) {
            console.error('Error fetching records:', error);
        }
        return allRecords;
    };

なお、パラメータoffset方式で処理できるレコード数は10,000件が上限ですので、10,000件超の場合はカーソルAPIを使用する必要があります。詳細は、以下のページを参照して下さい。

アプリの応答時間に注意が必要

クラウド型DBのレコード処理では、応答時間が課題になります。
実際に5000件位のレコードがあるアプリで試したところ、APIの呼出回数が10回(1回500レコード×10回)で、結果が表示されるまで5-6秒の時間が必要でした。
5-6秒でもアプリがエラーで停止したのか?と思うほど長く感じましたw

アプリ毎のAPIリクエストの制限値

kintoneのAPIリクエストは、アプリ毎に1日10,000回までの制限があります。例えば10万レコードが有るアプリで全てのレコードを読み込む処理を行うと1回の処理で200回(10万/500回)のAPIリクエストを行うので、1日に50回以上繰り返し処理すると制限値(200×50=10,000回)に到達します。

なお、アプリ毎の1日に実行できるAPIリクエスト回数の制限値(10,000回)に到達してもアプリは動作しますが、翌日にCybouz社から「APIリクエストの制限を超えてるので注意して下さい」という警告メールがシステム管理者宛に飛んできます。
※以前、10万超のレコードを一括更新処理した際に経験しました(汗)

kinitoneドメイン毎の同時リクエストの制限値

kintone REST APIは、1つのドメインにつき100件まで同時実行が許可されており、101件目以降のリクエストはエラーになります。
制限値を超過した場合、原因となったアプリだけではなく、ドメイン全体でkintone REST APIのリクエストがエラーとなるため注意してください。
出典:https://cybozu.dev/ja/id/a3826ad89e2921695859c31d/#restriction

cybozu developer network

レコード登録数が大量のアプリで全レコードを対象にするカスタマイズを行うと、API処理でアプリの応答時間が遅くなり他のアプリの動作にも影響が出ることもあります。
個人的には、アプリの全レコードを対象にするカスタマイズでは、レコード数5000件以下(APIリクエスト10回以下)が安全運用の目安だと思います。
※標準機能だけを使っているアプリでは上記の様な制限はありません。

レコード一覧機能のカスタマイズは奥が深いですね。
今回も最後まで読んで頂いてありがとうございました。


📩 ご相談ください!

この様なカスタマイズをご希望の方は、以下の「お仕事依頼」のページから、是非当社にご相談ください!

いいなと思ったら応援しよう!

アプリ活用研究会(キン活) よろしければサポートお願いします! いただいたサポートは、note記事制作の活動費に使わせていただきます!