Skip to content

入力支援 ​

第8版作成 最終更新 (日本時間)
確認バージョン1.4.18.01.5.3.01.5.8.1

編集画面での入力を助けるカスタマイズをまとめます。いずれも本体を改修せず、拡張 HTML・拡張スタイル・拡張スクリプト(@メンションの通知は拡張サーバースクリプトも)で実装できます。

カスタマイズ使う機能外部ライブラリ
コメント欄に @メンション補完を追加する拡張 HTML + 拡張スタイル + 拡張スクリプト + 拡張サーバースクリプトTribute.js(CDN)
数値項目に電卓モーダルを追加する拡張スタイル + 拡張スクリプトなし(標準の jQuery / jQuery UI)
日付項目に年月(Ym)モードを追加する拡張 HTML + 拡張スタイル + 拡張スクリプトflatpickr と monthSelectPlugin(CDN)

コメント欄に @メンション補完を追加する ​

コメント欄で @ を入力するとユーザー候補をドロップダウンで表示し、選ぶと [To:ユーザーID] を挿入します。さらに、コメントの更新時に拡張サーバースクリプトでメンション先へメールを送ります。補完にはオートコンプリートライブラリの Tribute.js を使います。Tribute.js は @ などの任意のトリガー文字でドロップダウンを表示する軽量なライブラリで、フレームワークに依存せず textarea にそのまま適用できます。

コメント欄で @ を入力するとユーザー候補が表示される

仕組み ​

図を読み込み中…

要素内容
ユーザー一覧の取得拡張スクリプトから $p.apiUsersGet を呼ぶと、ログイン中のセッションで API を実行するため API キーは不要です。View の ColumnFilterHash に SiteId を渡すと、そのサイトにアクセス権を持つユーザーだけに絞り込まれます(省略するとテナント内の全ユーザー)。ApiGetMailAddresses: true を付けるとメールアドレスも返ります
コメント欄id="Comments" の textarea です。tribute.attach(element) でオートコンプリートを追加します
再アタッチAjax によるページ更新で DOM が作り直される場合があるため、ajaxComplete で再アタッチします。data-tribute-attached 属性で二重アタッチを防ぎます
ユーザー情報(サーバー側)サーバースクリプトの組み込みオブジェクト users.Get(userId) で取得できるため、拡張 SQL は不要です

users.Get() で取得できる主なプロパティは次のとおりです。

js
var user = users.Get(123);
// user.UserId    → ユーザーID
// user.LoginId   → ログインID
// user.Name      → 表示名
// user.DeptId    → 組織ID
// user.Disabled  → 無効フラグ

サーバースクリプトで model.Comments を JSON.parse() すると、次の形式の配列になります。新しいコメントが先頭に追加されるため、comments[0] が最新のコメントです。

json
[
    {
        "CommentId": 3,
        "CreatedTime": "2026-03-01T10:00:00",
        "Creator": 1,
        "Body": "[To:5] 確認お願いします"
    },
    {
        "CommentId": 2,
        "CreatedTime": "2026-02-28T15:00:00",
        "Creator": 2,
        "Body": "対応しました"
    }
]

準備 ​

パラメータ設定
App_Data/Parameters/Notification.jsonメールで通知する場合は Mail が true であることを確認します
App_Data/Parameters/Api.jsonコード例は ApiVersion: 1.1 を指定しています。Compatibility_1_3_12 が false(既定値)であることを確認します。true の場合、セッション認証($p.api~ 関数)で ApiVersion の指定が無視されます
json
{
    "Mail": true
}

INFO

API バージョンの違いやパラメータの詳細は、公式マニュアルの「既定のAPIバージョン 1.1 への変更および旧バージョンとの互換性について」を参照してください。

設定 ​

次のファイルを App_Data/Parameters/ 配下に配置します。

text
App_Data/Parameters/
├── ExtendedHtmls/
│   └── HtmlHeaderBottom.html          ← Tribute.js の読み込み
├── ExtendedStyles/
│   └── CommentMention.css             ← ドロップダウンのスタイル調整
├── ExtendedScripts/
│   └── CommentMention.js              ← オートコンプリートの初期化
└── ExtendedServerScripts/
    ├── CommentMention.json            ← 通知の設定ファイル
    └── CommentMention.json.js         ← 通知のスクリプト本体

ライブラリの読み込み(拡張 HTML) ​

Tribute.js を jsDelivr から読み込みます。JavaScript は HtmlHeaderBottom.html、CSS は同じファイルまたは HtmlBodyBottom.html に書きます。既に HtmlHeaderBottom.html がある場合は、既存の内容に 2 行を追記してください。

html
<script src="https://cdn.jsdelivr.net/npm/tributejs@5.1.3/dist/tribute.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tributejs@5.1.3/dist/tribute.css">

ドロップダウンのスタイル(拡張スタイル) ​

Tribute.js の既定のスタイルを、プリザンターの画面になじむように調整します。

ExtendedStyles/CommentMention.css
css
/* --- @メンション補完のスタイル --- */

/* ドロップダウンコンテナ */
.tribute-container {
    border: 1px solid #d0d0d0;
    border-radius: 6px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
    background: #fff;
    max-height: 240px;
    overflow-y: auto;
    z-index: 10000;
    font-size: 14px;
}

/* 各候補アイテム */
.tribute-container li {
    padding: 8px 14px;
    cursor: pointer;
    border-bottom: 1px solid #f0f0f0;
    display: flex;
    align-items: center;
    gap: 8px;
}

.tribute-container li:last-child {
    border-bottom: none;
}

/* ホバー・選択中 */
.tribute-container li.highlight,
.tribute-container li:hover {
    background: #e8f0fe;
}

/* ユーザー名 */
.mention-name {
    font-weight: bold;
    color: #333;
}

/* 組織名 */
.mention-dept {
    font-size: 12px;
    color: #888;
}

/* ログインID */
.mention-login-id {
    font-size: 12px;
    color: #aaa;
    margin-left: auto;
}

/* コメント欄に挿入されたメンションの見た目(プレビュー時) */
.md .mention-tag {
    color: #1a73e8;
    font-weight: bold;
}

オートコンプリートの初期化(拡張スクリプト) ​

$p.apiUsersGet で現在のサイトにアクセスできるユーザーを取得してキャッシュし、Tribute.js をコメント欄にアタッチします。候補は名前とログイン ID で検索でき、選ぶと [To:ユーザーID] を挿入します。

なお、Users API の応答(UserApiModel)には DeptId はありますが組織名(DeptName)のプロパティはないため、下のコードの組織名欄(.mention-dept)は空になります(UserApiModel.cs)。組織名を出したい場合は、DeptId から組織名を引く処理を別途追加してください。

ExtendedScripts/CommentMention.js
js
(function () {
    // Tribute.js が読み込まれていなければ終了
    if (typeof Tribute === 'undefined') {
        return;
    }

    var tribute = null;
    var usersCache = null;

    // サイトにアクセス可能なユーザー一覧を取得してキャッシュ
    function fetchUsers(callback) {
        if (usersCache) {
            callback(usersCache);
            return;
        }
        $p.apiUsersGet({
            data: {
                ApiVersion: 1.1,
                View: {
                    ColumnFilterHash: {
                        SiteId: $p.siteId().toString()
                    },
                    ApiGetMailAddresses: true
                }
            },
            done: function (data) {
                if (data && data.Response && data.Response.Data) {
                    usersCache = data.Response.Data.map(function (u) {
                        return {
                            userId: u.UserId,
                            name: u.Name,
                            loginId: u.LoginId,
                            // Users API の応答に組織名は含まれないため、通常は空になります
                            deptName: u.DeptName || '',
                            mailAddress: (u.MailAddresses && u.MailAddresses[0]) || ''
                        };
                    });
                } else {
                    usersCache = [];
                }
                callback(usersCache);
            },
            fail: function () {
                usersCache = [];
                callback(usersCache);
            }
        });
    }

    // Tribute.js を初期化
    function createTribute(users) {
        return new Tribute({
            trigger: '@',
            values: users.map(function (u) {
                return {
                    key: u.name + ' ' + u.loginId,
                    value: u.userId.toString(),
                    name: u.name,
                    loginId: u.loginId,
                    deptName: u.deptName
                };
            }),
            selectTemplate: function (item) {
                return '[To:' + item.original.value + ']';
            },
            menuItemTemplate: function (item) {
                return '<span class="mention-name">' + item.original.name + '</span>'
                    + '<span class="mention-dept">' + item.original.deptName + '</span>'
                    + '<span class="mention-login-id">@' + item.original.loginId + '</span>';
            },
            noMatchTemplate: '<li class="no-match">該当するユーザーが見つかりません</li>',
            lookup: 'key',
            fillAttr: 'value',
            requireLeadingSpace: false
        });
    }

    // コメント欄に Tribute をアタッチ
    function attachToComments() {
        var el = document.getElementById('Comments');
        if (!el || el.dataset.tributeAttached) {
            return;
        }
        fetchUsers(function (users) {
            if (!tribute) {
                tribute = createTribute(users);
            }
            tribute.attach(el);
            el.dataset.tributeAttached = 'true';
        });
    }

    // 初期化
    attachToComments();

    // AJAX によるページ更新後に再アタッチ
    var debounceTimer;
    $(document).ajaxComplete(function () {
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(attachToComments, 200);
    });
})();

メンション通知(拡張サーバースクリプト) ​

AfterUpdate でコメントの追加を検知し、最新のコメントから [To:ユーザーID] を抽出して、対象ユーザー(無効なユーザーは除く)にメールを送ります。

json
{
    "Name": "CommentMention",
    "Description": "コメントのメンション通知",
    "Disabled": false,
    "AfterUpdate": true,
    "TryCatch": true,
    "Body": ""
}

拡張サーバースクリプトの .json は 1 ファイルにつき 1 つのオブジェクトとして読み込まれ、同名の .json.js があればその内容が Body に入ります。配列([ ... ])で書くと読み込みに失敗して起動時にエラーになるため、オブジェクトで書きます(Initializer.cs の読み込み処理)。

ExtendedServerScripts/CommentMention.json.js
js
try {
    // 今回の更新でコメントが入力されていなければ終了
    if (!context.Forms.Data('Comments')) {
        return;
    }

    // 現在のコメント一覧を取得
    var comments = JSON.parse(model.Comments);
    if (!comments || comments.length === 0) {
        return;
    }

    // 最新のコメント(先頭)を取得
    var latest = comments[0];
    if (!latest || !latest.Body) {
        return;
    }
    var body = latest.Body;

    // [To:userId] パターンを抽出(数値のユーザーID)
    var regex = /\[To:(\d+)\]/g;
    var match;
    var userIds = [];
    while ((match = regex.exec(body)) !== null) {
        var userId = parseInt(match[1], 10);
        if (userId > 0 && userIds.indexOf(userId) === -1) {
            userIds.push(userId);
        }
    }

    if (userIds.length === 0) {
        return;
    }

    // メンション通知の本文を組み立て
    var recordTitle = model.Title;

    // 各メンション対象にメール通知を送信
    for (var i = 0; i < userIds.length; i++) {
        // users.Get() でユーザー情報を取得(拡張SQL不要)
        var user = users.Get(userIds[i]);

        if (!user || user.Disabled) {
            continue;
        }

        // メール通知を送信
        var notification = notifications.New();
        notification.Type = 1; // Mail
        notification.Address = user.LoginId;
        notification.Title = '[メンション] ' + recordTitle;
        notification.Body = user.Name + ' さん宛のメンションがあります。\n\n'
            + '---\n'
            + body + '\n'
            + '---\n\n'
            + 'レコード: ' + recordTitle;
        notification.Send();
    }
} catch (e) {
    context.Log(JSON.stringify({
        message: 'CommentMention error',
        error: e.message || e.toString()
    }));
}

コメントが追加されたかどうかは、リクエストのフォーム値 context.Forms.Data('Comments')(新しいコメントの入力欄の値)が空でないかで判定しています。サーバースクリプトの model に Comments_Updated のようなプロパティはないため(model にはカラムの値だけが入ります。ServerScriptModel.cs)、model.Comments_Updated で判定すると常に処理が終了してしまいます。また AfterUpdate の時点ではレコードが再取得されていて saved.Comments も更新後の値になるため(ResultModel.cs)、model と saved の比較も使えません。そこでフォーム値で判定しています。フォームのキー Comments は、更新時に新しいコメントとして先頭に追加される値です(ResultModel.cs)。

notifications.New() はサーバースクリプトの組み込みオブジェクト notifications のメソッドで、Type の既定値はメール(1)です。Title と Body を設定して Send() を呼ぶと送信されます(ServerScriptModelNotificationModel.cs)。

通知オブジェクトの主なプロパティは次のとおりです。

プロパティ型説明
Typeint通知タイプ(1: メール、2: Slack、3: ChatWork)
Addressstring送信先アドレス
Titlestring件名
Bodystring本文
TokenstringAPI トークン(Slack・ChatWork などの場合)

WARNING

notification.Address には通知先のメールアドレスを指定します。上の例ではログイン ID を指定しているため、ログイン ID がメールアドレス形式でない環境では、拡張 SQL で MailAddresses テーブルからメールアドレスを取得してください。

この通知は標準の通知設定とは独立して動作するため、既存の通知には影響しません。

数値項目に電卓モーダルを追加する ​

数値項目(NumA〜NumZ)の入力欄に電卓ボタンを追加し、クリックで電卓モーダルを開きます。「単価×数量」「割引後の値」などを計算し、OK で結果をそのまま数値項目にセットできます。外部ライブラリは使わず、プリザンター標準の jQuery / jQuery UI(.dialog())だけで動作し、v1・v2 どちらのテーマでも使えます。

  • 四則演算、%(百分率への変換)、符号反転(±)に対応します。演算子を続けて押すと、前の計算結果に続けて演算します(連鎖計算)。
  • 計算式を上部にリアルタイムで表示します。
  • モーダルを開くと、数値項目の現在値をプリセットします。
  • キーボード(数字キー、+ - * / =、Enter、Backspace、Escape)でも操作できます。
  • 電卓ボタンを付ける項目の範囲を 3 つのモードから選べます。

数値項目の電卓ボタンから開く電卓モーダル

INFO

バージョン 1.5.3.0 を対象にしています。

仕組み ​

図を読み込み中…

電卓モーダルの構成は次のとおりです。

text
┌──────────────────────┐
│  3 × 12 =            │  ← 計算式エリア(グレー背景)
│                   36 │  ← 現在値エリア(大きめフォント)
├────┬────┬────┬───────┤
│ C  │DEL │ %  │  ÷   │
├────┼────┼────┼───────┤
│ 7  │ 8  │ 9  │  ×   │
├────┼────┼────┼───────┤
│ 4  │ 5  │ 6  │  −   │
├────┼────┼────┼───────┤
│ 1  │ 2  │ 3  │  +   │
├────┼────┼────┼───────┤
│ ±  │ 0  │ .  │  =   │
└────┴────┴────┴───────┘
       [キャンセル] [OK]
操作処理
数字calc.current に追記。calc.reset フラグが立っていれば入力をリセットしてから追記
演算子calc.prev に現在値を保存し calc.op に演算子を記録。前の演算子が保留中なら先に計算を実行
イコールcalc.prev と calc.current を calc.op で計算し、結果を calc.current に格納
Cすべての状態をリセット
DELcalc.current の末尾を 1 文字削除
%calc.current の値を 100 で割る
±calc.current の符号を反転

浮動小数点の誤差(例: 0.1 + 0.2 = 0.30000000000000004)は、toFixed(10) で丸めてから parseFloat で不要なゼロを除いて対処しています。

js
// 例: 0.1 + 0.2 の計算結果
parseFloat((0.1 + 0.2).toFixed(10)).toString()
// → "0.3"

有効化モード ​

スクリプト先頭の CALC_MODE で、電卓ボタンを付ける項目の範囲を切り替えます。このサンプルが対象にするのは、コントロール種別が標準(テキストボックス)の数値項目です。スピナーの入力欄は control-textbox ではなく control-spinner クラスになるため対象外です(HtmlControls.cs)。また、セレクタの .field-normal は項目の「スタイル」が「ノーマル」のときにフィールドの div に付くクラスなので(「ワイド」にすると field-wide に置き換わります。HtmlFields.cs)、スタイルがワイドの数値項目も対象にしたい場合は findTargetFields のセレクタから .field-normal を外してください。

設定値フィールド CSS の設定動作
'all'不要すべての Normal の数値項目に追加
'css'対象項目に calc を設定指定した項目だけに追加
'no-css'除外項目に no-calc を設定指定した項目以外に追加

設定 ​

  1. CalcModal.css を App_Data/Parameters/ExtendedStyles/ に配置します。
  2. CalcModal.js を App_Data/Parameters/ExtendedScripts/ に配置します。
  3. CalcModal.js の先頭の CALC_MODE を用途に応じて設定します。

拡張スタイルの前半は v1 テーマ向けの固定値、後半は v2 テーマ向けの CSS カスタムプロパティによる上書きです。v2 テーマ(cerulean / green-tea / mandarin / midnight)では :root にカスタムプロパティが定義されるため配色がテーマに追従し、v1 テーマでは未定義のため前半の固定値(var() のフォールバック値)が使われます。

ExtendedStyles/CalcModal.css
css
/* =====================================================
   電卓モーダル専用スタイル
   ===================================================== */

/* ── 電卓ボタン(input右側の絶対配置) ────────── */

/* container-normal を基準に絶対配置 */
.container-normal:has(.calc-modal-trigger) {
  position: relative;
}

.calc-modal-trigger {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 100%;
  padding: 0 !important;
  margin: 0;
  line-height: 1;
  cursor: pointer;
  /* Material Symbols 設定 */
  font-family: 'Material Symbols Sharp';
  font-size: 16px;
  font-variation-settings: 'FILL' 1;
  font-feature-settings: 'liga';
  font-style: normal;
  font-weight: normal;
  text-transform: none;
  letter-spacing: normal;
  white-space: nowrap;
  direction: ltr;
  -webkit-font-smoothing: antialiased;
  user-select: none;
}

/* 単位の有無に関わらず、JS で位置と padding を動的に調整 */

/* ── ダイアログ本体(v1テーマ固定値) ────────────── */

.ui-dialog.calc-modal {
  border: none;
  border-radius: 8px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
  padding: 0;
}

.ui-dialog.calc-modal .ui-dialog-titlebar {
  background: #fff;
  border: none;
  border-bottom: 1px solid #e0e0e0;
  border-radius: 8px 8px 0 0;
  padding: 12px 16px;
  font-size: 15px;
  font-weight: bold;
  color: #333;
}

.ui-dialog.calc-modal .ui-dialog-titlebar-close {
  display: none;
}

.ui-dialog.calc-modal .ui-dialog-content {
  padding: 12px;
  background: #fff;
}

/* ── 計算式エリア ─────────────────────────────────── */

.calc-modal-expr {
  width: 100%;
  box-sizing: border-box;
  min-height: 18px;
  padding: 4px 8px;
  margin-bottom: 4px;
  background: #f5f5f5;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  font-size: 12px;
  color: #888;
  text-align: right;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── 現在値エリア ─────────────────────────────────── */

.calc-modal-current {
  width: 100%;
  box-sizing: border-box;
  padding: 8px 10px;
  margin-bottom: 10px;
  background: #fff;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 22px;
  font-weight: bold;
  font-family: monospace;
  color: #222;
  text-align: right;
  word-break: break-all;
}

/* ── ボタングリッド ───────────────────────────────── */

.calc-modal-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 6px;
}

.calc-modal-key {
  padding: 10px 0;
  font-size: 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #fff;
  color: #333;
  cursor: pointer;
  text-align: center;
  user-select: none;
  transition: background 0.1s;
}

.calc-modal-key:hover  { background: #f0f0f0; }
.calc-modal-key:active { background: #e0e0e0; }

/* 演算子キー */
.calc-modal-key.op {
  background: #e8f0fe;
  color: #1a73e8;
  border-color: #aac4f5;
  font-weight: bold;
}
.calc-modal-key.op:hover { background: #d2e3fc; }

/* イコールキー */
.calc-modal-key.eq {
  background: #1a73e8;
  color: #fff;
  border-color: #1557b0;
  font-weight: bold;
}
.calc-modal-key.eq:hover { background: #1557b0; }

/* クリアキー */
.calc-modal-key.clear {
  background: #fce8e6;
  color: #d93025;
  border-color: #f5b0aa;
  font-weight: bold;
}
.calc-modal-key.clear:hover { background: #f5c6c2; }

/* DEL キー */
.calc-modal-key.del {
  background: #fef3e2;
  color: #ea8600;
  border-color: #f8d7a0;
}
.calc-modal-key.del:hover { background: #fde7c0; }

/* ── ボタン領域 ───────────────────────────────────── */

.ui-dialog.calc-modal .ui-dialog-buttonpane {
  border-top: 1px solid #e0e0e0;
  padding: 10px 16px;
  background: #fafafa;
  border-radius: 0 0 8px 8px;
  margin-top: 0;
}

.ui-dialog.calc-modal .ui-dialog-buttonpane button {
  border: none;
  border-radius: 4px;
  padding: 8px 24px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.2s;
  margin-left: 8px;
}

.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-ok {
  background: #1a73e8;
  color: #fff;
}
.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-ok:hover {
  background: #1557b0;
}

.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-cancel {
  background: #e0e0e0;
  color: #333;
}
.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-cancel:hover {
  background: #bdbdbd;
}

/* ── v2テーマ対応(CSSカスタムプロパティで自動切替) ── */
/* v2テーマ(cerulean / green-tea / mandarin / midnight)では
   :root にカスタムプロパティが定義されるため以下で上書きされます。
   v1テーマではカスタムプロパティが未定義のため
   上の固定値がそのまま使われます。                        */

.ui-dialog.calc-modal {
  box-shadow: 0 8px 32px var(--base-shadow, rgba(0, 0, 0, 0.25));
}

.ui-dialog.calc-modal .ui-dialog-titlebar {
  background: var(--base-bg, #fff);
  border-bottom-color: var(--base-border, #e0e0e0);
  color: var(--base-text, #333);
}

.ui-dialog.calc-modal .ui-dialog-content {
  background: var(--base-bg, #fff);
}

.calc-modal-expr {
  background: var(--table-header-bg, #f5f5f5);
  border-color: var(--base-border, #e0e0e0);
  color: var(--base-text-light, #888);
}

.calc-modal-current {
  background: var(--base-bg, #fff);
  border-color: var(--base-border, #ccc);
  color: var(--base-text, #222);
}

.calc-modal-key {
  background: var(--base-bg, #fff);
  color: var(--base-text, #333);
  border-color: var(--base-border, #ccc);
}
.calc-modal-key:hover { background: var(--base-hover-bg, #f0f0f0); }

.calc-modal-key.op {
  background: var(--btn-secondary-bg, #e8f0fe);
  color: var(--primaryColor, #1a73e8);
  border-color: var(--btn-secondary-border, #aac4f5);
}

.calc-modal-key.eq {
  background: var(--btn-positive-bg, #1a73e8);
  color: var(--btn-positive-label, #fff);
  border-color: var(--btn-positive-border, #1557b0);
}

.calc-modal-key.clear {
  background: var(--btn-delete-bg, #fce8e6);
  color: var(--btn-delete-label, #d93025);
  border-color: var(--btn-delete-border, #f5b0aa);
}

.ui-dialog.calc-modal .ui-dialog-buttonpane {
  background: var(--base-bg-light, #fafafa);
  border-top-color: var(--base-border, #e0e0e0);
}

.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-ok {
  background: var(--btn-positive-bg, #1a73e8);
  color: var(--btn-positive-label, #fff);
}
.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-ok:hover {
  background: var(--btn-positive-hover, #1557b0);
}

.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-cancel {
  background: var(--btn-normal-bg, #e0e0e0);
  color: var(--btn-normal-label, #333);
}
.ui-dialog.calc-modal .ui-dialog-buttonpane button.calc-btn-cancel:hover {
  background: var(--btn-normal-hover, #bdbdbd);
}
  • .ui-dialog.calc-modal でスコープを限定し、標準のダイアログに影響しないようにしています。
  • .calc-modal-grid を display: grid; grid-template-columns: repeat(4, 1fr) にして電卓のキー配列を作っています。演算子・イコール・クリアのキーには色を付けています。
  • タイトルバーの閉じるボタン(.ui-dialog-titlebar-close)は非表示にし、キャンセル / OK ボタンで閉じるようにしています。
ExtendedScripts/CalcModal.js
js
$(function () {
  // ┌────────────────────────────────────────┐
  // │  マスター設定(環境に合わせて変更)    │
  // └────────────────────────────────────────┘

  // 有効化モード
  //   'all'    : すべての数値項目に電卓ボタンを追加
  //   'css'    : フィールド CSS に calc を設定した項目だけ
  //   'no-css' : フィールド CSS に no-calc を設定した項目以外
  // ※このサンプルは ControlType=Normal の項目のみ対象
  var CALC_MODE = 'all';

  // ========================================
  // 電卓ロジック
  // ========================================

  var calc = {
    current: '0', // 現在の入力値
    prev: null,   // 前の値(演算子入力後)
    op: null,     // 保留中の演算子
    reset: false, // 次の入力でリセット
    expr: ''      // 表示用の計算式
  };

  function resetCalc() {
    calc.current = '0';
    calc.prev = null;
    calc.op = null;
    calc.reset = false;
    calc.expr = '';
  }

  // 数字・小数点の入力
  function inputDigit(d) {
    if (calc.reset) {
      calc.current = d === '.' ? '0.' : d;
      calc.reset = false;
      return;
    }
    if (d === '.') {
      if (calc.current.indexOf('.') >= 0) return;
      calc.current += '.';
    } else {
      calc.current = calc.current === '0' ? d : calc.current + d;
    }
  }

  // 計算実行
  function compute(a, b, op) {
    switch (op) {
      case '+': return a + b;
      case '−': return a - b;
      case '×': return a * b;
      case '÷': return b !== 0 ? a / b : NaN;
    }
    return b;
  }

  // 浮動小数点誤差対策つきフォーマット
  function fmtNum(v) {
    if (isNaN(v) || !isFinite(v)) return 'エラー';
    return parseFloat(v.toFixed(10)).toString();
  }

  // 演算子キーの入力
  function inputOp(op) {
    var v = parseFloat(calc.current);
    if (isNaN(v)) return;
    if (calc.op && !calc.reset) {
      v = compute(calc.prev, v, calc.op);
      calc.current = fmtNum(v);
    }
    calc.expr = calc.current + ' ' + op;
    calc.prev = v;
    calc.op = op;
    calc.reset = true;
  }

  // イコールキーの入力
  function inputEquals() {
    if (calc.op === null || calc.reset) return;
    var b = parseFloat(calc.current);
    if (isNaN(b)) return;
    var result = compute(calc.prev, b, calc.op);
    calc.expr = calc.expr + ' ' + calc.current + ' =';
    calc.current = fmtNum(result);
    calc.prev = null;
    calc.op = null;
    calc.reset = true;
  }

  // DEL(1文字削除)
  function inputDel() {
    if (calc.reset) return;
    calc.current = calc.current.length <= 1 ? '0'
      : calc.current.slice(0, -1);
  }

  // キー入力の統合処理
  function handleKey(key) {
    if (/^[0-9]$/.test(key)) {
      inputDigit(key);
    } else if (key === '.') {
      inputDigit('.');
    } else if (key === 'C') {
      resetCalc();
    } else if (key === 'DEL') {
      inputDel();
    } else if (key === '+' || key === '−' || key === '×' || key === '÷') {
      inputOp(key);
    } else if (key === '=') {
      inputEquals();
    } else if (key === '%') {
      var pct = parseFloat(calc.current);
      if (!isNaN(pct)) calc.current = fmtNum(pct / 100);
    } else if (key === '±') {
      if (calc.current !== '0' && calc.current !== 'エラー') {
        calc.current = calc.current[0] === '-'
          ? calc.current.slice(1)
          : '-' + calc.current;
      }
    }
    updateDisplay();
  }

  // ディスプレイの更新
  function updateDisplay() {
    $('#calc-modal-expr').text(calc.expr);
    var text = calc.current;
    var size = text.length > 12 ? '14px' : text.length > 8 ? '18px' : '22px';
    $('#calc-modal-current').text(text).css('font-size', size);
  }

  // ========================================
  // モーダル制御
  // ========================================

  var CALC_HTML = [
    '<div class="calc-modal-expr" id="calc-modal-expr"></div>',
    '<div class="calc-modal-current" id="calc-modal-current">0</div>',
    '<div class="calc-modal-grid">',
    '  <button class="calc-modal-key clear" data-key="C">C</button>',
    '  <button class="calc-modal-key del"   data-key="DEL">DEL</button>',
    '  <button class="calc-modal-key op"    data-key="%">%</button>',
    '  <button class="calc-modal-key op"    data-key="÷">÷</button>',
    '  <button class="calc-modal-key"       data-key="7">7</button>',
    '  <button class="calc-modal-key"       data-key="8">8</button>',
    '  <button class="calc-modal-key"       data-key="9">9</button>',
    '  <button class="calc-modal-key op"    data-key="×">×</button>',
    '  <button class="calc-modal-key"       data-key="4">4</button>',
    '  <button class="calc-modal-key"       data-key="5">5</button>',
    '  <button class="calc-modal-key"       data-key="6">6</button>',
    '  <button class="calc-modal-key op"    data-key="−">−</button>',
    '  <button class="calc-modal-key"       data-key="1">1</button>',
    '  <button class="calc-modal-key"       data-key="2">2</button>',
    '  <button class="calc-modal-key"       data-key="3">3</button>',
    '  <button class="calc-modal-key op"    data-key="+">+</button>',
    '  <button class="calc-modal-key"       data-key="±">±</button>',
    '  <button class="calc-modal-key"       data-key="0">0</button>',
    '  <button class="calc-modal-key"       data-key=".">.</button>',
    '  <button class="calc-modal-key eq"    data-key="=">=</button>',
    '</div>'
  ].join('\n');

  function openCalcModal($input) {
    resetCalc();

    // 数値項目の現在値をプリセット
    var raw = ($input.val() || '').replace(/,/g, '');
    var num = parseFloat(raw);
    if (!isNaN(num)) calc.current = fmtNum(num);

    var $dlg = $('<div id="calc-modal-dialog" title="電卓"></div>')
      .html(CALC_HTML)
      .appendTo('body');

    $dlg.dialog({
      dialogClass: 'calc-modal',
      modal: true,
      width: 300,
      resizable: false,
      draggable: false,
      buttons: [
        {
          text: 'キャンセル',
          'class': 'calc-btn-cancel',
          click: function () { $dlg.dialog('close'); }
        },
        {
          text: 'OK',
          'class': 'calc-btn-ok',
          click: function () {
            if (calc.current !== 'エラー') {
              var v = parseFloat(calc.current);
              if (!isNaN(v)) {
                $input.val(v).trigger('change');
              }
            }
            $dlg.dialog('close');
          }
        }
      ],
      close: function () {
        $(document).off('keydown.calcModal');
        $dlg.dialog('destroy').remove();
      }
    });

    updateDisplay();

    // ボタンのクリック
    $dlg.on('click', '.calc-modal-key', function () {
      handleKey($(this).data('key'));
    });

    // キーボード入力
    $(document).on('keydown.calcModal', function (e) {
      var map = {
        '0':'0', '1':'1', '2':'2', '3':'3', '4':'4',
        '5':'5', '6':'6', '7':'7', '8':'8', '9':'9',
        '.':'.', '+':'+', '-':'−', '*':'×', '/':'÷',
        '=':'=', 'Enter':'='
      };
      var key = map[e.key];
      if (key) {
        e.preventDefault();
        handleKey(key);
      } else if (e.key === 'Backspace') {
        e.preventDefault();
        handleKey('DEL');
      } else if (e.key === 'Escape') {
        $dlg.dialog('close');
      }
    });
  }

  // ========================================
  // 数値項目への電卓ボタン追加
  // ========================================

  function findTargetFields() {
    var $fields = $('[id$="Field"].field-normal');

    switch (CALC_MODE) {
      case 'css':
        return $fields.filter('.calc');
      case 'no-css':
        return $fields.not('.no-calc');
      default: // 'all'
        return $fields;
    }
  }

  function setupNumFields() {
    findTargetFields().each(function () {
      var $field = $(this);
      var $input = $field
        .find('.container-normal input.control-textbox[data-validate-number]')
        .first();
      if (!$input.length) return;

      if ($field.find('.calc-modal-trigger').length) return;

      var $btn = $(
        '<span class="calc-modal-trigger" title="電卓" aria-label="電卓" role="button" tabindex="0">calculate</span>'
      );
      $btn.on('click', function (e) {
        e.preventDefault();
        e.stopPropagation();
        openCalcModal($input);
      });
      $btn.on('keydown', function (e) {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          openCalcModal($input);
        }
      });

      var $container = $field.find('.container-normal').first();
      if ($container.length) {
        $container.append($btn);
        
        var $unit = $container.find('.unit').first();
        var btnWidth = Math.round($btn.outerWidth());  // CSSの幅を動的に取得
        var margin = 4;     // マージン
        
        var measurePosition = function () {
          if ($unit.length) {
            // 単位がある場合: 単位幅を測定して整数値に丸める
            var unitWidth = Math.round($unit.outerWidth());
            var rightOffset = unitWidth + margin;
            $btn.css('right', rightOffset + 'px');
            $input.css('padding-right', (rightOffset + btnWidth) + 'px');
          } else {
            // 単位がない場合: ボタン幅とマージンで計算
            $btn.css('right', '0');
            $input.css('padding-right', (btnWidth + margin) + 'px');
          }
        };
        
        // 初回測定(必要に応じて遅延)
        if (!$unit.length || $unit.width() > 0) {
          measurePosition();
        } else {
          setTimeout(measurePosition, 50);
        }
      }
    });
  }

  // ========================================
  // 初期化と Ajax 再読み込み対応
  // ========================================

  var debounce;
  $(document).ajaxComplete(function () {
    clearTimeout(debounce);
    debounce = setTimeout(setupNumFields, 100);
  });

  setupNumFields();
});

ポイント ​

図を読み込み中…

  • 対象は [id$="Field"].field-normal のうち、.container-normal 内に input.control-textbox[data-validate-number] を持つ項目です。
  • 電卓ボタン(Material Symbols の calculate)はフィールドラベルの横ではなく、.container-normal の末尾に追加し、CalcModal.css で入力欄の右端に絶対配置します。JavaScript で設定するのは、単位の有無に応じたボタンの right と入力欄の padding-right だけです。単位が設定されている項目では単位(span.unit)の幅を測って、ボタンを単位の左側に置きます。確認したソースでは、数値項目の入力欄と単位は .container-normal の中の div.input-field にまとめて出力されます(HtmlControls.cs、HtmlFields.cs)。
  • モーダルを開くときは、入力欄の値からカンマを除いて現在値としてプリセットします。OK を押すと $input.val(v).trigger('change') で値をセットします。
  • Ajax による再描画に備え、ajaxComplete で(100ms のデバウンス付きで)再セットアップします。既にボタンがある項目には追加しません。
  • Enterprise Edition の項目拡張で増やした Num001 以降の項目も、セレクタが一致すれば同様に動作します。

WARNING

App_Data/Parameters 配下の拡張スクリプト・拡張スタイルは、起動時にまとめて読み込まれます(Initializer.cs)。ファイルを置いたり変更したりしただけでは反映されないため、プリザンターを再起動するか、特権ユーザーで /admins/reloadparameters にアクセスしてパラメータを再読み込みしてください(AdminsController.cs)。詳しくは パラメータ を参照してください。

日付項目に年月モードを追加する ​

日付項目で「年と月だけ」を選べるようにします。月単位の実績管理や、月次レポートの対象月を選ぶ場面などで使えます。flatpickr の monthSelectPlugin を使い、一覧の書式が「年月」(Ym)に設定された日付項目を自動検出して適用します。選んだ値は 2026/03/1 00:00 のように、選択月の 1 日 0 時として保存されます。

年月モードの日付ピッカー(月だけを選ぶ)

INFO

バージョン 1.4.18.0 以降(日付ピッカーに flatpickr が採用されている環境)を対象にしています。Api.json の Compatibility_1_3_12 は false(既定値)を前提とします。

仕組み ​

項目内容
方針flatpickr の monthSelectPlugin で年月選択の UI を追加
対象画面編集画面($p.action() が 'edit' または 'new')
対象カラム一覧の書式が Ym に設定されたカラムを自動検出
値の形式Y/m/1 00:00(選択月の 1 日 0 時)
CDN の読み込み拡張 HTML(HtmlHeaderBottom.html)で jsDelivr から読み込み

図を読み込み中…

CDN から flatpickr を読み込む理由 ​

プリザンターは flatpickr を内部にバンドルしていますが、date-field カスタム要素の内部で ES モジュールとしてインポートしているため、拡張スクリプトからグローバルの flatpickr 関数を呼べません。そこで、CDN から同じバージョン(v4.6.13)の flatpickr を読み込み、グローバルスコープで使えるようにします。同じバージョンなので既存の CSS と競合せず、monthSelectPlugin のスタイルだけを追加すれば動作します。

対象カラムの自動検出 ​

日付カラムの「一覧の書式」(GridFormat)は、サイト設定(SiteSettings)の JSON に保存されます。

json
{
  "Columns": [
    {
      "ColumnName": "DateA",
      "GridFormat": "Ym"
    }
  ]
}

編集画面の date-field の data-format 属性には EditorFormat(エディタの書式)しか反映されず、GridFormat は含まれません。そこでサイト取得 API(POST /api/items/{サイトID}/getsite)でサイト設定を取得し、Response.Data.SiteSettings.Columns のうち GridFormat が Ym のカラムを抽出します。

サイト ID を渡した $p.apiGet(/api/items/{サイトID}/get)はそのサイトのレコード一覧を返す API で、サイト設定は含まれません。1.5.8.1 では getsite がサイトの情報を 1 件のオブジェクトとして返し、SiteSettings はその中にオブジェクトで入っています(ItemsController.cs#L198-L216、SiteUtilities.cs#L1945-L1978、SiteModel.cs#L2295-L2328)。

GridFormat の値一覧の書式
Ym年月
Md月日
Ymd年月日
Ymda年月日曜
Ymdhm日付と時刻(分)
Ymdhms日付と時刻(秒)
Ymdahm日付と曜日と時刻(分)
Ymdahms日付と曜日と時刻(秒)

選択肢は App_Data/Displays/ の Type が 120 の表示名から作られます(SiteUtilities.cs)。本体のピッカーに年月モードを組み込む場合の設計は 日付項目の入力モードと表示形式の拡張 にあります。

設定 ​

拡張 HTML・拡張スタイル・拡張スクリプトの 3 ファイルを配置します。

CDN の読み込み(拡張 HTML) ​

App_Data/Parameters/ExtendedHtmls/ に配置します。既に HtmlHeaderBottom.html がある場合は、既存の内容に追記してください。

html
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/ja.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/plugins/monthSelect/index.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/plugins/monthSelect/style.css">
ファイル役割
flatpickr.min.jsflatpickr 本体(グローバルに flatpickr 関数を公開)
l10n/ja.js日本語ロケール
plugins/monthSelect/index.js月選択プラグイン(グローバルに monthSelectPlugin 関数を公開)
plugins/monthSelect/style.css月選択 UI のスタイル

スタイル(拡張スタイル) ​

App_Data/Parameters/ExtendedStyles/ に配置します。monthSelectPlugin の既定では選択中の月が青系で表示されるため、var(--primaryColor) に置き換えてテーマ(cerulean / green-tea / mandarin / midnight)の色に合わせます。

css
/* 選択中の月をプリザンターのテーマカラーに合わせる */
.flatpickr-monthSelect-month.selected {
  background-color: var(--primaryColor);
}

Ym モードの適用(拡張スクリプト) ​

App_Data/Parameters/ExtendedScripts/ に配置します。

ExtendedScripts/YmMode.js
js
$(function () {
  if ($p.action() !== 'edit' && $p.action() !== 'new') return;

  // サイト設定から GridFormat が Ym のカラムを自動検出して適用
  detectYmColumns(function (ymColumns) {
    if (ymColumns.length === 0) return;

    // date-field カスタム要素の初期化完了を待って適用
    customElements.whenDefined('date-field').then(function () {
      ymColumns.forEach(applyYmMode);
    });

    // Ajax 再読み込み対応(更新・作成後の再描画に追従)
    var debounceTimer;
    $(document).ajaxComplete(function () {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(function () {
        ymColumns.forEach(applyYmMode);
      }, 200);
    });
  });

  function detectYmColumns(callback) {
    // サイト設定はレコード取得(get)ではなくサイト取得(getsite)で返る
    $p.apiExec($('#ApplicationPath').val() + 'api/items/' + $p.siteId() + '/getsite', {
      data: { ApiVersion: 1.1 },
      done: function (data) {
        try {
          var ss = data.Response.Data.SiteSettings;
          if (typeof ss === 'string') ss = JSON.parse(ss);
          var columns = (ss && ss.Columns) || [];
          callback(
            columns
              .filter(function (c) { return c.GridFormat === 'Ym'; })
              .map(function (c) { return c.ColumnName; })
          );
        } catch (e) {
          callback([]);
        }
      },
      fail: function () {
        callback([]);
      }
    });
  }

  function applyYmMode(columnName) {
    var $input = $p.getControl(columnName);
    if (!$input.length) return;
    var input = $input[0];

    // 既に適用済みならスキップ
    if (input.dataset.ymMode === '1') return;
    input.dataset.ymMode = '1';

    // 既存の flatpickr インスタンスを破棄
    if (input._flatpickr) {
      input._flatpickr.destroy();
    }

    // monthSelectPlugin 付きで再初期化
    flatpickr(input, {
      plugins: [
        new monthSelectPlugin({
          shorthand: true,
          dateFormat: 'Y/m/j 00:00',
          theme: 'light'
        })
      ],
      disableMobile: true,
      locale: 'ja',
      onChange: function (_selectedDates, dateStr) {
        $p.set($input, dateStr);
      }
    });

    // 「現在」ボタンの動作を上書き
    overrideCurrentButton(input, $input);
  }

  function overrideCurrentButton(input, $input) {
    var dateFieldEl = input.closest('date-field');
    if (!dateFieldEl || !dateFieldEl.shadowRoot) return;

    var currentBtn = dateFieldEl.shadowRoot.querySelector('.current-date');
    if (!currentBtn) return;

    // イベントリスナーを差し替えるためクローンで置換
    var newBtn = currentBtn.cloneNode(true);
    currentBtn.parentNode.replaceChild(newBtn, currentBtn);

    newBtn.addEventListener('click', function () {
      var now = new Date();
      var y = now.getFullYear();
      var m = String(now.getMonth() + 1).padStart(2, '0');
      var val = y + '/' + m + '/1 00:00';
      input._flatpickr.setDate(new Date(y, now.getMonth(), 1), true);
      $p.set($input, val);
    });
  }
});

ポイント ​

処理内容
date-field の初期化待ちdate-field はカスタム要素(Web Components)です。$(function(){...}) の時点ではまだアップグレードが終わっていない場合があるため、customElements.whenDefined() で使えるようになるまで待ちます
既存インスタンスの破棄date-field が初期化した flatpickr のインスタンスは input._flatpickr に入っています。destroy() で破棄してから monthSelectPlugin 付きで再初期化します
値の反映月を選ぶと onChange で $p.set() を呼び、値を反映します
「現在」ボタンdate-field の Shadow DOM 内にある「現在」ボタン(時計アイコン)は元のインスタンスを参照しています。cloneNode(true) で複製すると元のイベントリスナーは引き継がれないため、複製に置き換えてから、当月の 1 日 0 時をセットするハンドラーだけを登録します
再描画への追従ajaxComplete で(200ms のデバウンス付きで)再適用します。data-ym-mode で適用済みの項目はスキップします

dateFormat: 'Y/m/j 00:00' は flatpickr のフォーマットトークンです。

トークン意味出力例
Y4 桁の年2026
m2 桁の月(ゼロ埋め)03
j日(ゼロ埋めなし)1
00:00リテラル文字列00:00

monthSelectPlugin は選んだ月の 1 日を内部でセットするため、j は常に 1 になり、時刻部分にはリテラルの 00:00 が付きます。結果として 2026/03/1 00:00 のような値になります。

monthSelectPlugin のテーマは 'light'(既定)と 'dark' の 2 種類です。暗いテーマにする場合は theme: 'dark' に変えます。

js
  new monthSelectPlugin({
    shorthand: true,
    dateFormat: 'Y/m/j 00:00',
    theme: 'dark'
  })

関連ページ ​

変更履歴

第8版記事の確認版を繰り返す表現を整理する
第7版入力支援のレシピにスクリーンショットを追加し、年月モードがサイト設定を正しく取得するよう修正
第6版履歴タブと復元・数値項目の通貨記号・画像プレビューモーダルの解説と、編集画面まわりの改修・設計メモを追加
第5版本文から元記事や以前の版への言及を除き、正しい動作だけを書く形に整理
第4版画面カスタマイズ集のコードを 1.5.8.1 のソースで検証し、動かなかったサンプルを修正
第3版画面カスタマイズ集にコード全体を収録し、サイト画像とサイト種別アイコンを両立するレシピを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版編集画面のレシピを拡充し、入力支援(@メンション・電卓・年月モード)を追加