入力支援
編集画面での入力を助けるカスタマイズをまとめます。いずれも本体を改修せず、拡張 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() で取得できる主なプロパティは次のとおりです。
var user = users.Get(123);
// user.UserId → ユーザーID
// user.LoginId → ログインID
// user.Name → 表示名
// user.DeptId → 組織ID
// user.Disabled → 無効フラグサーバースクリプトで model.Comments を JSON.parse() すると、次の形式の配列になります。新しいコメントが先頭に追加されるため、comments[0] が最新のコメントです。
[
{
"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 の指定が無視されます |
{
"Mail": true
}INFO
API バージョンの違いやパラメータの詳細は、公式マニュアルの「既定のAPIバージョン 1.1 への変更および旧バージョンとの互換性について」を参照してください。
設定
次のファイルを App_Data/Parameters/ 配下に配置します。
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 行を追記してください。
<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
/* --- @メンション補完のスタイル --- */
/* ドロップダウンコンテナ */
.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
(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] を抽出して、対象ユーザー(無効なユーザーは除く)にメールを送ります。
{
"Name": "CommentMention",
"Description": "コメントのメンション通知",
"Disabled": false,
"AfterUpdate": true,
"TryCatch": true,
"Body": ""
}拡張サーバースクリプトの .json は 1 ファイルにつき 1 つのオブジェクトとして読み込まれ、同名の .json.js があればその内容が Body に入ります。配列([ ... ])で書くと読み込みに失敗して起動時にエラーになるため、オブジェクトで書きます(Initializer.cs の読み込み処理)。
ExtendedServerScripts/CommentMention.json.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)。
通知オブジェクトの主なプロパティは次のとおりです。
| プロパティ | 型 | 説明 |
|---|---|---|
Type | int | 通知タイプ(1: メール、2: Slack、3: ChatWork) |
Address | string | 送信先アドレス |
Title | string | 件名 |
Body | string | 本文 |
Token | string | API トークン(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 を対象にしています。
仕組み
図を読み込み中…
電卓モーダルの構成は次のとおりです。
┌──────────────────────┐
│ 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 | すべての状態をリセット |
| DEL | calc.current の末尾を 1 文字削除 |
| % | calc.current の値を 100 で割る |
| ± | calc.current の符号を反転 |
浮動小数点の誤差(例: 0.1 + 0.2 = 0.30000000000000004)は、toFixed(10) で丸めてから parseFloat で不要なゼロを除いて対処しています。
// 例: 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 を設定 | 指定した項目以外に追加 |
設定
CalcModal.cssをApp_Data/Parameters/ExtendedStyles/に配置します。CalcModal.jsをApp_Data/Parameters/ExtendedScripts/に配置します。CalcModal.jsの先頭のCALC_MODEを用途に応じて設定します。
拡張スタイルの前半は v1 テーマ向けの固定値、後半は v2 テーマ向けの CSS カスタムプロパティによる上書きです。v2 テーマ(cerulean / green-tea / mandarin / midnight)では :root にカスタムプロパティが定義されるため配色がテーマに追従し、v1 テーマでは未定義のため前半の固定値(var() のフォールバック値)が使われます。
ExtendedStyles/CalcModal.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
$(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 に保存されます。
{
"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 がある場合は、既存の内容に追記してください。
<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.js | flatpickr 本体(グローバルに 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)の色に合わせます。
/* 選択中の月をプリザンターのテーマカラーに合わせる */
.flatpickr-monthSelect-month.selected {
background-color: var(--primaryColor);
}Ym モードの適用(拡張スクリプト)
App_Data/Parameters/ExtendedScripts/ に配置します。
ExtendedScripts/YmMode.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 のフォーマットトークンです。
| トークン | 意味 | 出力例 |
|---|---|---|
Y | 4 桁の年 | 2026 |
m | 2 桁の月(ゼロ埋め) | 03 |
j | 日(ゼロ埋めなし) | 1 |
00:00 | リテラル文字列 | 00:00 |
monthSelectPlugin は選んだ月の 1 日を内部でセットするため、j は常に 1 になり、時刻部分にはリテラルの 00:00 が付きます。結果として 2026/03/1 00:00 のような値になります。
monthSelectPlugin のテーマは 'light'(既定)と 'dark' の 2 種類です。暗いテーマにする場合は theme: 'dark' に変えます。
new monthSelectPlugin({
shorthand: true,
dateFormat: 'Y/m/j 00:00',
theme: 'dark'
})