WEB 伝票形式明細行 HTML(PYTHON.400/QHTMLSRC.DENPYO)
最終更新:
2025-11-28
0001.00 <!DOCTYPE html>
0002.00 % import ibm_db2
0003.00 <html lang="ja">
0004.00 <head>
0005.00 <meta charset="utf-8">
0006.00 <meta http-equiv="Content-Language" content="ja">
0007.00 <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
0008.00 <meta http-equiv="Pragma" content="no-cache">
0009.00 <meta http-equiv="cache-control" content="no-cache">
0010.00 <meta http-equiv="Expires" content="-1">
0011.00 <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
0012.00 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
0013.00 <title>$TEXT</title>
0014.00 <style>
0015.00 html {
0016.00 font-family: "BIZ UPDGothic", Meiryo, sans-serif;
0017.00 accent-color: rgb(26, 115, 232);
0018.00 color: #555;
0019.00 margin: 0;
0020.00 box-sizing: border-box;
0021.00 height: 100%;
0022.00 }
0023.00 body {
0024.00 margin: 0;
0025.00 padding: 1em 4em 3em;
0026.00 box-sizing: border-box;
0027.00 height: 100%;
0028.00 display: flex;
0029.00 flex-direction: column;
0030.00 }
0031.00 header {
0032.00 flex: 0;
0033.00 }
0034.00 footer {
0035.00 flex: 0;
0036.00 }
0037.00 .content {
0038.00 flex: 1;
0039.00 min-height: 0;
0040.00 max-width: calc(100%);
0041.00 overflow: auto;
0042.00 }
0043.00 main {
0044.00 min-height: 0;
0045.00 flex: 1;
0046.00 display: flex;
0047.00 flex-direction: column;
0048.00 align-items: flex-start;
0049.00 }
0050.00 form {
0051.00 display: contents;
0052.00 }
0053.00 input {
0054.00 font-family: inherit;
0055.00 font-size: inherit;
0056.00 color: inherit;
0057.00 }
0058.00 input:read-only {
0059.00 border-color: transparent;
0060.00 }
0061.00 h1 {
0062.00 font-size: 2.4em;
0063.00 font-weight: normal;
0064.00 color: #8888ff;
0065.00 }
0066.00 fieldset {
0067.00 display: grid;
0068.00 grid-template-columns: repeat(3, 20em);
0069.00 gap: 0.2em;
0070.00 border: 0;
0071.00 margin: 1em 0;
0072.00 }
0073.00 .span-2 {
0074.00 grid-column: span 2;
0075.00 }
0076.00 .span-3 {
0077.00 grid-column: span 3;
0078.00 }
0079.00 label {
0080.00 display: flex;
0081.00 align-items: baseline;
0082.00 gap: 0 0.7em;
0083.00 }
0084.00 .name {
0085.00 flex: none;
0086.00 width: 8em;
0087.00 }
0088.00 header .name::after,
0089.00 footer .name::after
0090.00 {
0091.00 content: ':';
0092.00 }
0093.00 #record-table thead tr th {
0094.00 color: #333;
0095.00 font-weight: normal;
0096.00 text-align: left;
0097.00 border-bottom: 2px solid hsl(220, 90%, 56%);
0098.00 }
0099.00 #record-table {
0100.00 border-collapse: separate;
0101.00 }
0102.00 #record-table th {
0103.00 padding-bottom: 0.75em;
0104.00 background-color: white;
0105.00 position: sticky;
0106.00 top: 0;
0107.00 }
0108.00 button > img {
0109.00 width: 1.2em;
0110.00 height: 1.2em;
0111.00 object-fit: contain;
0112.00 vertical-align: text-bottom;
0113.00 margin-right: 0.3em;
0114.00 }
0115.00 .dspmod {
0116.00 position: absolute;
0117.00 top: 1em;
0118.00 right: 3em;
0119.00 font-size: 2.5em;
0120.00 color: #8888ff;
0121.00 }
0122.00 </style>
0123.00 </head>
0124.00 <body data-dspmod="{{dspmod}}">
0125.00 <form action="/DSPDTA" method="post" id="record-form">
0126.00 <input type="hidden" name="_CHG" value="*NO">
0127.00 <header>
0128.00 <h1>$TEXT</h1>
0129.00 % if dspmod == 'DSPPTN':
0130.00 <div class="dspmod"> 表示 </div>
0131.00 % elif dspmod == 'CHGPTN':
0132.00 <div class="dspmod"> 変更 </div>
0133.00 % else:
0134.00 <div class="dspmod"> 入力 </div>
0135.00 % end
0136.00 <div class="buttons">
0137.00 <button type="submit" formaction="/end"><img src="/icon/END.SVG"> 終了 </button>
0138.00 % if dspmod == "CHGPTN":
0139.00 <button type="submit" formaction="/update"><img src="/icon/UPDATE.SVG"> 更新 </button>
0140.00 <button type="submit" formaction="/delete"><img src="/icon/DELETE.SVG"> 削除 </button>
0141.00 % elif dspmod == "INPPTN":
0142.00 <button type="submit" formaction="/insert"><img src="/icon/UPDATE.SVG"> 更新 </button>
0143.00 % end
0144.00 <button type="button" class="default" onclick="location.href='/init';"><img src="/icon/BACK.SVG">
0145.00 </div>
0146.00 <!-- ヘッダー -->
0147.00 <fieldset>
0148.00 <label$CLASS>
0149.00 <span class="name">$COLHDG</span>
0150.00 <span class="value">
0151.00 <input type="text" name="$FLD" value="{{values[0].get('$FLD', '')}}"
0152.00 size="$SIZE" maxlength="$FLDLEN" {{"readonly" if dspmod != "INPPTN" else ""}}$ALIGN>
0153.00 </span>
0154.00 </label>
0155.00 </fieldset>
0156.00 </header>
0157.00 <!-- サブファイル -->
0158.00 <main>
0159.00 <div class="content">
0160.00 <table id="record-table">
0161.00 <thead>
0162.00 <tr>
0163.00 <th class="checkbox">
0164.00 </th>
0165.00 <th>
0166.00 <span class="name">$COLHDG</span>
0167.00 </th>
0168.00 </tr>
0169.00 </thead>
0170.00 <tbody>
0171.00 </tbody>
0172.00 </table>
0173.00 </div>
0174.00 </main>
0175.00 <footer>
0176.00 <!-- フッター -->
0177.00 <fieldset>
0178.00 <label$CLASS>
0179.00 <span class="name">$COLHDG</span>
0180.00 <span class="value">
0181.00 <input type="text" name="$FLD" value="{{values[0].get('$FLD', '')}}"
0182.00 size="$SIZE" maxlength="$FLDLEN" {{"readonly" if dspmod == "DSPPTN" else ""}}$ALIGN>
0183.00 </span>
0184.00 </label>
0185.00 </fieldset>
0186.00 </footer>
0187.00 </main>
0188.00 <footer>
0189.00 </footer>
0190.00 </form>
0191.00 <form name="key">
0192.00 <input type="hidden" name="$KEYFLD" value="{{values[0].get('$KEYFLD', '')}}">
0193.00 </form>
0194.00 <template id="empty-record">
0195.00 </template>
0196.00 <script>
0197.00 "use strict";
0198.00
0199.00 /**
0200.00 * 要素の一番下までスクロールされたかどうか監視する。
0201.00 */
0202.00 class ScrollEndObserver {
0203.00 #callback;
0204.00 #threshold = 0;
0205.00 #targets = new Map();
0206.00 constructor(callback, options) {
0207.00 this.#callback = callback;
0208.00 const effective_options = {
0209.00 threshold: 0,
0210.00 ...options,
0211.00 };
0212.00 this.#threshold = effective_options.threshold;
0213.00 }
0214.00 /**
0215.00 * container 要素の監視を開始する。
0216.00 */
0217.00 observe(container) {
0218.00 if(!this.#targets.has(container)) {
0219.00 const onscroll = this.#onscroll.bind(this);
0220.00 container.addEventListener("scroll", onscroll, {
0221.00 passive: true,
0222.00 });
0223.00 this.#targets.set(container, {
0224.00 onscroll,
0225.00 scrollHeight: 0,
0226.00 clientHeight: 0,
0227.00 });
0228.00 }
0229.00 }
0230.00 /**
0231.00 * container 要素の監視を終了する。
0232.00 */
0233.00 unobserve(container) {
0234.00 const entry = this.#targets.get(container);
0235.00 if(entry) {
0236.00 container.removeEventListener("scroll", entry.onscroll);
0237.00 this.#targets.delete(container);
0238.00 }
0239.00 }
0240.00 /**
0241.00 * 全ての要素の監視を終了する。
0242.00 */
0243.00 disconnect() {
0244.00 this.#targets.forEach((value, key) => {
0245.00 this.unobserve(key);
0246.00 });
0247.00 }
0248.00 /**
0249.00 * onscroll イベントのリスナー
0250.00 */
0251.00 #onscroll(event) {
0252.00 const target = event.target;
0253.00 const entry = this.#targets.get(target);
0254.00
0255.00 // 監視対象でない場合は終了。
0256.00 if(!entry)
0257.00 return;
0258.00
0259.00 const scrollHeight = target.scrollHeight;
0260.00 const clientHeight = target.clientHeight;
0261.00
0262.00 // 要素の大きさが以前と変わっていないなら終了。
0263.00 // ( 不必要な callback が発生するのを避けるため。 )
0264.00 if(scrollHeight === entry.scrollHeight && clientHeight === entry.clientHeight)
0265.00 return;
0266.00
0267.00 // データの追加が必要なら callback する。
0268.00 if(this.needsMoreContent(target)) {
0269.00 entry.scrollHeight = scrollHeight;
0270.00 entry.clientHeight = clientHeight;
0271.00 this.#callback({
0272.00 target,
0273.00 scrollHeight,
0274.00 clientHeight,
0275.00 }, this);
0276.00 }
0277.00 }
0278.00 /**
0279.00 * 指定のコンテナにデータの追加が必要かどうか判定する。
0280.00 */
0281.00 needsMoreContent(target) {
0282.00 const scrollHeight = target.scrollHeight;
0283.00 const clientHeight = target.clientHeight;
0284.00 const scrollTop = target.scrollTop;
0285.00
0286.00 return scrollTop >= scrollHeight - clientHeight - (clientHeight * (1.0 - this.#threshold));
0287.00 }
0288.00 }
0289.00
0290.00 /**
0291.00 * この画面特有の処理
0292.00 */
0293.00 class App {
0294.00 #endOfData = false; // 最後のページまでデータを読み込んだ。
0295.00 #scrollEndObserver;
0296.00 #container = document.querySelector(".content");
0297.00 #header = document.querySelector("header");
0298.00 #main = document.querySelector("main");
0299.00 #footer = document.querySelector("footer");
0300.00 #tbody = document.querySelector("#record-table tbody");
0301.00 #recordForm = document.getElementById("record-form");
0302.00 #emptyRecord = null;
0303.00
0304.00 constructor() {
0305.00 this.#scrollEndObserver = new ScrollEndObserver((entry, observer) => {
0306.00 this.readPage();
0307.00 }, {
0308.00 threshold: 0.8,
0309.00 });
0310.00 }
0311.00
0312.00 /**
0313.00 * ページが読み込まれたときに最初に実行される処理。
0314.00 */
0315.00 async start() {
0316.00 // ユーザーがヘッダーまたはフッターを変更したときは目印をつける。
0317.00 [this.#header, this.#footer].forEach(element => element.addEventListener("input", event => {
0318.00 document.querySelector("input[name='_CHG']").value = "*YES";
0319.00 }));
0320.00 // ユーザーが明細レコードを変更した時はそのレコードに目印を付ける。
0321.00 this.#main.addEventListener("input", event => {
0322.00 const tr = event.target.closest("tr");
0323.00 if(tr)
0324.00 tr.setAttribute("data-modified", "");
0325.00 });
0326.00
0327.00 // ユーザーが変更したレコードのみを submit する。
0328.00 this.#recordForm.addEventListener("submit", event => {
0329.00 // 変更されていないレコードに一時的に disabled をつける。
0330.00 this.#tbody.querySelectorAll("tr:not([data-modified])").forEach(tr => {
0331.00 tr.querySelectorAll("input:enabled, select:enabled, textarea:enabled").forEach(element => {
0332.00 element.setAttribute("data-disabled-temporarily", "");
0333.00 element.disabled = true;
0334.00 });
0335.00 });
0336.00 // submit 後に元に戻す。
0337.00 setTimeout(() => {
0338.00 this.#tbody.querySelectorAll("[data-disabled-temporarily").forEach(element => {
0339.00 element.disabled = false;
0340.00 element.removeAttribute("data-disabled-temporarily");
0341.00 });
0342.00 }, 0);
0343.00 });
0344.00 // 削除ボタンの処理
0345.00 document.querySelectorAll("[formaction='/delete']").forEach(e => {
0346.00 e.addEventListener("click", event => {
0347.00 const count = this.#tbody.querySelectorAll("input[name='_CHG']:checked").length;
0348.00 if(count >= 1) {
0349.00 // チェックされている明細が存在する→明細の削除
0350.00 if(!window.confirm(` 明細 ${count} 件を削除しますか? `)) {
0351.00 event.preventDefault();
0352.00 }
0353.00 } else {
0354.00 // チェックされている明細が存在しない→ヘッダー毎削除
0355.00 if(!window.confirm(" この伝票を削除しますか? ")) {
0356.00 event.preventDefault();
0357.00 }
0358.00 }
0359.00 });
0360.00 });
0361.00 // 入力画面の場合はサーバーからデータを読み取らない。
0362.00 if(document.body.matches("[data-dspmod='INPPTN']")) {
0363.00 this.#endOfData = true;
0364.00 }
0365.00 // 最初から表示されている部分をサーバーから読み取る。
0366.00 await this.readPage();
0367.00 // スクロールの監視を開始する。
0368.00 this.#scrollEndObserver.observe(this.#container);
0369.00 }
0370.00 /**
0371.00 * 入力用の空行の HTML をサーバーから取得する。
0372.00 */
0373.00 async fetchEmptyRecord() {
0374.00 return await this.fetchText("/template", {
0375.00 method: "GET",
0376.00 cache: "no-cache",
0377.00 });
0378.00 }
0379.00
0380.00 /**
0381.00 * 入力用の空行の要素を取得する。
0382.00 */
0383.00 async getEmptyRecord() {
0384.00 if(!this.#emptyRecord) {
0385.00 // サーバーから空行の HTML を取得する。
0386.00 this.#emptyRecord = new Promise(async resolve => {
0387.00 const html = await this.fetchEmptyRecord();
0388.00 const emptyRecord = document.getElementById("empty-record");
0389.00 emptyRecord.innerHTML = html;
0390.00 resolve(emptyRecord);
0391.00 });
0392.00 }
0393.00 return (await this.#emptyRecord).content.cloneNode(true);
0394.00 }
0395.00
0396.00 /**
0397.00 * 入力用の空行を一行追加する。
0398.00 */
0399.00 async appendEmptyRecord(key) {
0400.00 const emptyRecord = await this.getEmptyRecord();
0401.00 emptyRecord.querySelector("input[type='text']").value = key;
0402.00 this.#tbody.append(emptyRecord);
0403.00 }
0404.00 /**
0405.00 * 非同期通信でテキストを受信する。
0406.00 */
0407.00 async fetchText(...args) {
0408.00 let response;
0409.00 try {
0410.00 response = await fetch(...args);
0411.00 } catch(error) {
0412.00 throw new Error(` 通信エラーが発生しました。 : nn${error}`);
0413.00 }
0414.00 if(!response.ok) {
0415.00 throw new Error(` サーバーがエラーを返しました : ${response.status} ${response.statusText}`);
0416.00 }
0417.00 try {
0418.00 return await response.text();
0419.00 } catch(error) {
0420.00 throw new Error(` サーバーからの応答を解釈出来ません : nn${error}`);
0421.00 }
0422.00 }
0423.00 /**
0424.00 * 指定のレコード番号から始まるデータをサーバーに問い合わせる。
0425.00 */
0426.00 async fetchNext(index) {
0427.00 const formData = new FormData(document.querySelector("form[name='key']"));
0428.00 formData.append("_RRN", index)
0429.00 return await this.fetchText("/record", {
0430.00 method: "POST",
0431.00 body: formData,
0432.00 cache: "no-store",
0433.00 });
0434.00 }
0435.00
0436.00 /**
0437.00 * サーバーから一画面分受け取って画面に表示する。
0438.00 */
0439.00 async readPage() {
0440.00 const tbody = document.querySelector("tbody");
0441.00 do {
0442.00 const index = tbody.querySelectorAll("tr").length;
0443.00 if(this.#endOfData) {
0444.00 // 既に最後のデータまでサーバーから受け取っている。
0445.00 if(document.body.matches(":not([data-dspmod='DSPPTN'])")) {
0446.00 // 入力画面なら空行を追加する。
0447.00 await this.appendEmptyRecord(index + 1);
0448.00 } else
0449.00 break;
0450.00 } else {
0451.00 let text;
0452.00 try {
0453.00 text = await this.fetchNext(index);
0454.00 } catch(error) {
0455.00 alert(error);
0456.00 break;
0457.00 }
0458.00 if(text.length === 0) {
0459.00 // 最後のレコードまで読み取った。
0460.00 this.#endOfData = true;
0461.00 }
0462.00 tbody.innerHTML += text;
0463.00 }
0464.00 } while(this.#scrollEndObserver.needsMoreContent(this.#container));
0465.00 }
0466.00 }
0467.00
0468.00 const app = new App();
0469.00 app.start();
0470.00 </script>
0471.00 </body>
0472.00 </html>
[解説]
このHTMLテンプレートは伝票型式のWeb画面の明細行の表示として
使われます。
0013.00 <title>$TEXT</title>
=> $TEXTはPython適用業務のテキストに置換えられます。
0149.00 <span class="name">$COLHDG</span>
0150.00 <span class="value">
0151.00 <input type="text" name="$FLD" value="{{values[0].get('$FLD', '')}}"
0152.00 size="$SIZE" maxlength="$FLDLEN" {{"readonly" if dspmod != "INPPTN" else ""}}$ALIGN>
0153.00 </span>
0154.00 </label>
がフィールドの記述であり
$COLHDG = フィールドの欄見出し
$FLD = フィールド名
$FLDLEN = フィールド長
$SIZE = フィールド長 + 1
に置換えられます。