SOURCE CODE

Apps Scriptへ登録するコード

次の3ファイルを、それぞれ対応するApps Scriptファイルへ貼り付けます。各「コードをコピー」ボタンは、表示を開かなくても全文をコピーできます。

公開用の初期コードです。実際の個人用URL、トークン、PIN、DriveフォルダID、スプレッドシートIDは含まれていません。運用後のシート内容やプロパティを、このサイトへ転載しないでください。

1. サーバーコード

Apps Scriptのスクリプトファイルへ貼り付けます。表示名は Code.gs または コード.gs で構いません。

Code.gs 1,278行 / 49.4 KB ファイルを保存
コード全文を表示
/**
 * 業務日報アプリ ─ サーバーサイド
 *
 * 認証   : 個人別トークン(URLパラメータ t)+ 任意のPIN
 * デプロイ: 実行者 = 自分 / アクセス = 全員(匿名ユーザーを含む)
 *          → 利用者にスプレッドシートを共有しないこと。アクセスは全てこのコード経由になる。
 */

const SHEET_REPORT = '日報';
const SHEET_USER   = 'ユーザー';
const SHEET_PJ     = 'PJ';
const SHEET_CONFIG = '設定';
const SHEET_LOG    = 'ログ';

const H_REPORT = ['date', 'userId', 'focus', 'schedule', 'unplanned', 'insights',
                  'tmrFocus', 'tmrPlan', 'carryover', 'memo', 'custom', 'updatedAt', 'nextDate'];
const H_USER   = ['userId', 'name', 'email', 'token', 'pin', 'active', 'url', 'createdAt', 'lastAccess'];
const H_PJ     = ['code', 'name', 'active', 'note'];
const H_CONFIG = ['userId', 'nextRule', 'folderId', 'autoSubmit'];
const H_LOG    = ['timestamp', 'userId', 'name', 'action', 'detail'];

const DEFAULT_ROWS = 10;
const HOLIDAY_CAL  = 'ja.japanese#holiday@group.v.calendar.google.com';
const PROP_SS_ID        = 'SPREADSHEET_ID';
const LEGACY_PROP_SS_ID = 'DB_SPREADSHEET_ID';

// 同じサーバー呼び出しの中ではSpreadsheet/Sheetオブジェクトを再利用する。
// CacheServiceではなく実行中だけのメモ化なので、認証や設定の鮮度には影響しない。
let RUNTIME_SS_ = null;
let RUNTIME_SHEETS_ = {};

/** スプレッドシートUI内で実行された時に、親スプレッドシートIDを自動記録する。 */
function rememberActiveSpreadsheet_() {
  try {
    const ss = SpreadsheetApp.getActive();
    if (!ss) return null;
    PropertiesService.getScriptProperties().setProperty(PROP_SS_ID, ss.getId());
    RUNTIME_SS_ = ss;
    RUNTIME_SHEETS_ = {};
    return ss;
  } catch (e) {
    return null;
  }
}

/** ウェブアプリからも確実にDBスプレッドシートを開く。 */
function getSs_() {
  if (RUNTIME_SS_) return RUNTIME_SS_;

  // メニュー・setup・モーダル内ではアクティブな親をそのまま使い、IDも自動保存する。
  const active = rememberActiveSpreadsheet_();
  if (active) return active;

  const props = PropertiesService.getScriptProperties();
  const id = String(props.getProperty(PROP_SS_ID)
    || props.getProperty(LEGACY_PROP_SS_ID) || '').trim();
  if (!id) {
    throw new Error('DBスプレッドシートを特定できません。オーナーが元のスプレッドシートを一度再読み込みするか、初期セットアップを実行してください。');
  }
  try {
    RUNTIME_SS_ = SpreadsheetApp.openById(id);
    if (!props.getProperty(PROP_SS_ID)) props.setProperty(PROP_SS_ID, id);
    return RUNTIME_SS_;
  } catch (e) {
    throw new Error('DBスプレッドシートを開けません。オーナーが初期セットアップを再実行してください。');
  }
}

/* ================= 初期セットアップ ================= */

function onOpen() {
  rememberActiveSpreadsheet_();
  SpreadsheetApp.getUi().createMenu('業務日報')
    .addItem('管理画面を開く', 'openAdmin')
    .addSeparator()
    .addItem('初期セットアップを実行', 'setup')
    .addItem('選択行のユーザーとして開く(動作確認)', 'openDialog')
    .addItem('ウェブアプリのURLを表示', 'showWebAppUrl')
    .addToUi();
}

function onInstall(e) { onOpen(); setup(); }

/** シートを取得(無ければ作成し、ヘッダを補完) */
function getSh_(name, headers) {
  if (RUNTIME_SHEETS_[name]) return RUNTIME_SHEETS_[name];
  const ss = getSs_();
  let sh = ss.getSheetByName(name);
  const cache = CacheService.getScriptCache();
  const schemaKey = 'schema_ok_v2_' + name;

  if (!sh) {
    sh = ss.insertSheet(name);
    sh.getRange(1, 1, 1, headers.length).setValues([headers]);
    sh.setFrozenRows(1);
    try { cache.put(schemaKey, '1', 300); } catch (e) { /* キャッシュ失敗は無視 */ }
  } else {
    let schemaOk = false;
    try { schemaOk = cache.get(schemaKey) === '1'; } catch (e) { schemaOk = false; }
    if (!schemaOk) {
      const cur = sh.getRange(1, 1, 1, Math.max(sh.getLastColumn(), 1)).getValues()[0];
      if (cur.length < headers.length || cur[headers.length - 1] !== headers[headers.length - 1]) {
        sh.getRange(1, 1, 1, headers.length).setValues([headers]);
      }
      try { cache.put(schemaKey, '1', 300); } catch (e) { /* キャッシュ失敗は無視 */ }
    }
  }
  RUNTIME_SHEETS_[name] = sh;
  return sh;
}

function ensureSheets_() {
  getSh_(SHEET_REPORT, H_REPORT);
  getSh_(SHEET_USER, H_USER);
  getSh_(SHEET_PJ, H_PJ);
  getSh_(SHEET_CONFIG, H_CONFIG);
  getSh_(SHEET_LOG, H_LOG);
}


/** active列にチェックボックスの入力規則だけを設定し、空行に残った値は除去する */
function applyCheckboxValidation_(sh, headers, keyName) {
  const rowCount = sh.getMaxRows() - 1;
  if (rowCount < 1) return;

  const keyCol = headers.indexOf(keyName) + 1;
  const activeCol = headers.indexOf('active') + 1;
  const keys = sh.getRange(2, keyCol, rowCount, 1).getDisplayValues();
  const activeRange = sh.getRange(2, activeCol, rowCount, 1);
  const activeValues = activeRange.getValues();

  // 旧版setupで空行に書き込まれた false もここで回復する
  let runStart = -1;
  for (let i = 0; i <= rowCount; i++) {
    const stale = i < rowCount
      && !String(keys[i][0]).trim()
      && activeValues[i][0] !== '';
    if (stale && runStart < 0) runStart = i;
    if (!stale && runStart >= 0) {
      sh.getRange(runStart + 2, activeCol, i - runStart, 1).clearContent();
      runStart = -1;
    }
  }

  const rule = SpreadsheetApp.newDataValidation().requireCheckbox().build();
  activeRange.setDataValidation(rule);
}

/** 初期セットアップ(何度実行しても安全) */
function setup() {
  // ウェブアプリの google.script.run から呼ばれた場合は、変更前にここで失敗させる
  const ui = SpreadsheetApp.getUi();
  const ss = SpreadsheetApp.getActive();
  if (!ss) throw new Error('対象のスプレッドシートを取得できません。');
  const props = PropertiesService.getScriptProperties();
  props.setProperty(PROP_SS_ID, ss.getId());
  RUNTIME_SS_ = ss;
  RUNTIME_SHEETS_ = {};

  // ── 旧バージョン(email をキーにした日報)があれば退避
  const old = ss.getSheetByName(SHEET_REPORT);
  if (old && old.getLastColumn() >= 2) {
    const h = old.getRange(1, 1, 1, 2).getValues()[0];
    if (String(h[1]) === 'email') {
      const stamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyyMMdd_HHmm');
      old.setName(SHEET_REPORT + '_旧_' + stamp);   // データは消さず退避
    }
  }

  ensureSheets_();

  const styles = [
    [SHEET_REPORT, H_REPORT, { date: 92, userId: 70, focus: 220, schedule: 260, unplanned: 170,
      insights: 170, tmrFocus: 220, tmrPlan: 170, carryover: 170, memo: 190, custom: 190,
      updatedAt: 118, nextDate: 92 }, H_REPORT],
    [SHEET_USER, H_USER, { userId: 70, name: 120, email: 190, token: 250, pin: 60,
      active: 60, url: 320, createdAt: 118, lastAccess: 130 },
      ['userId', 'name', 'email', 'token', 'pin', 'url', 'createdAt', 'lastAccess']],
    [SHEET_PJ, H_PJ, { code: 110, name: 260, active: 60, note: 260 }, ['code', 'name', 'note']],
    [SHEET_CONFIG, H_CONFIG, { userId: 70, nextRule: 90, folderId: 260, autoSubmit: 90 },
      ['userId', 'nextRule', 'folderId']],
    [SHEET_LOG, H_LOG, { timestamp: 130, userId: 70, name: 110, action: 80, detail: 320 }, H_LOG]
  ];

  styles.forEach(function (s) {
    const sh = getSh_(s[0], s[1]);
    sh.getRange(1, 1, 1, s[1].length)
      .setFontWeight('bold').setBackground('#e3e8ef').setVerticalAlignment('middle');
    sh.setFrozenRows(1);
    sh.setRowHeight(1, 26);
    s[1].forEach(function (h, i) { sh.setColumnWidth(i + 1, s[2][h] || 120); });
    // 文字列で保持したい列(日付やPINが数値・日付に化けるのを防ぐ)
    s[3].forEach(function (h) {
      const c = s[1].indexOf(h) + 1;
      if (c > 0) sh.getRange(2, c, sh.getMaxRows() - 1, 1).setNumberFormat('@');
    });
    sh.getRange(2, 1, sh.getMaxRows() - 1, s[1].length)
      .setWrapStrategy(SpreadsheetApp.WrapStrategy.CLIP).setVerticalAlignment('top');
    if (sh.getMaxColumns() > s[1].length) {
      sh.deleteColumns(s[1].length + 1, sh.getMaxColumns() - s[1].length);
    }
    if (!sh.getProtections(SpreadsheetApp.ProtectionType.RANGE).some(function (p) {
      return p.getDescription() === s[0] + 'ヘッダ';
    })) {
      sh.getRange(1, 1, 1, s[1].length).protect().setDescription(s[0] + 'ヘッダ').setWarningOnly(true);
    }
  });

  // ── active 列はチェックボックスに(既存値を壊さない)
  const us = getSh_(SHEET_USER, H_USER);
  applyCheckboxValidation_(us, H_USER, 'userId');
  const ps = getSh_(SHEET_PJ, H_PJ);
  applyCheckboxValidation_(ps, H_PJ, 'code');

  // ── 未使用の初期シートを片付ける
  ss.getSheets().forEach(function (s) {
    if (s.getLastRow() === 0 && s.getLastColumn() === 0 && ss.getSheets().length > 1) ss.deleteSheet(s);
  });
  ss.setActiveSheet(us);

  const holidays = getJpHolidays_();
  const url = webAppUrl_();
  if (url) syncUserUrls_(url);

  const msg = 'セットアップが完了しました。\n\n'
    + '・シート: ' + [SHEET_REPORT, SHEET_USER, SHEET_PJ, SHEET_CONFIG, SHEET_LOG].join(' / ') + '\n'
    + '・祝日カレンダー: ' + (holidays.length ? '接続OK(' + holidays.length + '件)'
                                             : '未接続 → 土日のみスキップで動作します') + '\n'
    + '・デプロイ: ' + (url ? '済み' : 'まだ → [デプロイ]→[新しいデプロイ]→ ウェブアプリ\n'
        + '  実行者:「自分」/アクセス:「全員」') + '\n\n'
    + '次に[業務日報]→[管理画面を開く]から、ユーザーとPJを登録してください。\n'
    + '※ このスプレッドシート自体は誰にも共有しないでください。';
  ui.alert(msg);
  return msg;
}

/* ================= ユーザー管理(管理者用) ================= */

/**
 * Workspaceドメイン限定URLを、個人Gmail等からも開ける汎用URLへ正規化する。
 * 例: /a/pny.llc/macros/... または /a/macros/pny.llc/... のドメイン部分を除去する。
 */
function normalizeBaseUrl_(url) {
  url = String(url || '').trim().replace(/[?#].*$/, '').replace(/\/+$/, '');
  let m = url.match(/^https:\/\/script\.google\.com\/a\/[^\/]+\/macros\/s\/([^\/]+)\/exec$/);
  if (m) return 'https://script.google.com/macros/s/' + m[1] + '/exec';
  m = url.match(/^https:\/\/script\.google\.com\/a\/macros\/[^\/]+\/s\/([^\/]+)\/exec$/);
  if (m) return 'https://script.google.com/macros/s/' + m[1] + '/exec';
  return url;
}

function isValidBaseUrl_(url) {
  return /^https:\/\/script\.google\.com\/macros\/s\/[^\/]+\/exec$/.test(String(url || ''));
}

/**
 * 配布URLのベース。手動登録値を優先し、古いドメイン限定値が残っていれば自動移行する。
 * /dev は編集者専用なので、配布URLとしては採用しない。
 */
function webAppUrl_() {
  const props = PropertiesService.getScriptProperties();
  const saved = String(props.getProperty('webAppUrl') || '').trim();
  if (saved) {
    const normalizedSaved = normalizeBaseUrl_(saved);
    if (isValidBaseUrl_(normalizedSaved)) {
      if (normalizedSaved !== saved) props.setProperty('webAppUrl', normalizedSaved);
      return normalizedSaved;
    }
  }
  try {
    const u = normalizeBaseUrl_(ScriptApp.getService().getUrl() || '');
    return isValidBaseUrl_(u) ? u : '';
  } catch (e) {
    return '';
  }
}

/** ユーザーシートの表示用URL列を、現在の配布ベースURLで同期する。 */
function syncUserUrls_(base) {
  base = String(base || '').trim();
  if (!base) return;
  const sh = getSh_(SHEET_USER, H_USER);
  const last = sh.getLastRow();
  if (last < 2) return;

  const rows = sh.getRange(2, 1, last - 1, H_USER.length).getValues();
  const current = rows.map(function (v) { return String(v[H_USER.indexOf('url')] || ''); });
  const desired = rows.map(function (v) {
    const userId = String(v[H_USER.indexOf('userId')] || '').trim();
    const token = String(v[H_USER.indexOf('token')] || '').trim();
    return [userId && token ? base + '?t=' + token : ''];
  });
  const changed = desired.some(function (v, i) { return v[0] !== current[i]; });
  if (changed) {
    sh.getRange(2, H_USER.indexOf('url') + 1, desired.length, 1).setValues(desired);
  }
}

/* ================= 管理API(管理画面からのみ) ================= */

/**
 * 管理画面はスプレッドシートのメニューからのみ開く。
 * 開いた時に使い捨てのnonceを発行し、管理APIは全てそれを要求する。
 *
 * 【重要】ウェブアプリは「実行者=自分」で動くため、利用者が google.script.run から
 * 任意の関数を呼べてしまう。nonceを知らなければ管理APIは弾かれる、という設計。
 * 管理APIを追加する時は、必ず先頭で adminAuth_(nonce) を呼ぶこと。
 */
function newAdminNonce_() {
  const n = Utilities.getUuid();
  CacheService.getScriptCache().put('admin_' + n, '1', 21600);  // 6時間で失効
  return n;
}

function adminAuth_(nonce) {
  if (!nonce || CacheService.getScriptCache().get('admin_' + String(nonce)) !== '1') {
    throw new Error('管理画面のセッションが切れました。スプレッドシートのメニューから開き直してください。');
  }
  return true;
}

function openAdmin() {
  const ui = SpreadsheetApp.getUi();  // ウェブアプリからの直接呼び出しを先に遮断
  rememberActiveSpreadsheet_();
  ensureSheets_();
  const base = webAppUrl_();
  if (base) syncUserUrls_(base);  // pny.llc等を含む旧URL列も、管理画面を開いた時に修復する
  const t = HtmlService.createTemplateFromFile('admin');
  t.nonce = newAdminNonce_();
  t.webAppUrl = base;
  ui.showModalDialog(t.evaluate().setWidth(1200).setHeight(760), '業務日報 ─ 管理');
}

function isTrue_(v) { return v === true || String(v).toUpperCase() === 'TRUE'; }

function readUsers_() {
  const sh = getSh_(SHEET_USER, H_USER);
  const last = sh.getLastRow();
  if (last < 2) return [];
  const base = webAppUrl_();
  return sh.getRange(2, 1, last - 1, H_USER.length).getValues().map(function (v, i) {
    return {
      row: i + 2, userId: String(v[0]).trim(), name: String(v[1]), email: String(v[2]),
      token: String(v[3]).trim(), pin: String(v[4] || ''), active: isTrue_(v[5]),
      url: base && v[3] ? base + '?t=' + v[3] : '',
      createdAt: fmtDateTime_(v[7]), lastAccess: v[8] ? fmtDateTime_(v[8]) : ''
    };
  }).filter(function (u) { return u.userId; });
}

function readPjs_() {
  const sh = getSh_(SHEET_PJ, H_PJ);
  const last = sh.getLastRow();
  if (last < 2) return [];
  return sh.getRange(2, 1, last - 1, H_PJ.length).getValues()
    .map(function (v, i) {
      return { row: i + 2, code: String(v[0]).trim(), name: String(v[1]),
               active: isTrue_(v[2]), note: String(v[3] || '') };
    })
    .filter(function (p) { return p.code; });
}

/** 管理画面の初期データ */
function adminGetAll(nonce) {
  adminAuth_(nonce);
  return {
    webAppUrl: webAppUrl_(),
    users: readUsers_(),
    pjs: readPjs_(),
    currentMonth: Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM')
  };
}

/** yyyy-MM を検証する。 */
function validMonth_(v) {
  const s = String(v || '').trim();
  if (!/^\d{4}-\d{2}$/.test(s)) throw new Error('対象月が正しくありません。');
  const p = s.split('-').map(Number);
  if (p[1] < 1 || p[1] > 12) throw new Error('対象月が正しくありません。');
  return s;
}

/** HH:mm を0時からの分へ変換する。正しくない値はnull。 */
function timeToMinute_(v) {
  const m = String(v || '').trim().match(/^(\d{1,2}):(\d{2})$/);
  if (!m) return null;
  const h = Number(m[1]);
  const min = Number(m[2]);
  if (h < 0 || h > 23 || min < 0 || min > 59) return null;
  return h * 60 + min;
}

/**
 * 実績開始・終了から分数を求める。
 * 日をまたいで終了が開始より前の場合は翌日として扱う。
 * 未入力・不正値はnull。
 */
function actualDurationMinutes_(start, end) {
  const s = timeToMinute_(start);
  const e = timeToMinute_(end);
  if (s === null || e === null) return null;
  let diff = e - s;
  if (diff < 0) diff += 24 * 60;
  return diff;
}

/** 月次集計用の内部バケットを作る。 */
function monthlyBucket_(u) {
  return {
    userId: String(u.userId || ''),
    name: String(u.name || ''),
    active: u.active !== false,
    savedDates: {},
    submittedDates: {},
    actualDates: {},
    actualMinutes: 0,
    incompleteRows: 0,
    pjMinutes: {}
  };
}

/**
 * 管理画面の月次サマリー。
 * 保存・提出日数はログ、実績時間とPJ別時間は日報のschedule[].as/ae/pjから算出する。
 */
function adminGetMonthlySummary(nonce, monthStr) {
  adminAuth_(nonce);
  const month = validMonth_(monthStr
    || Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM'));

  const users = readUsers_();
  const buckets = {};
  users.forEach(function (u) {
    buckets[u.userId] = monthlyBucket_(u);
  });

  function getBucket_(userId, name) {
    userId = String(userId || '').trim();
    if (!userId) return null;
    if (!buckets[userId]) {
      buckets[userId] = monthlyBucket_({
        userId: userId,
        name: name || userId,
        active: false
      });
    } else if (!buckets[userId].name && name) {
      buckets[userId].name = String(name);
    }
    return buckets[userId];
  }

  const reportSh = getSh_(SHEET_REPORT, H_REPORT);
  const reportLast = reportSh.getLastRow();
  if (reportLast >= 2) {
    const reportVals = reportSh.getRange(2, 1, reportLast - 1, H_REPORT.length).getValues();
    reportVals.forEach(function (v) {
      const date = fmtDate_(v[0]);
      if (date.slice(0, 7) !== month) return;
      const b = getBucket_(v[1], '');
      if (!b) return;

      const schedule = parseJson_(v[H_REPORT.indexOf('schedule')], []);
      let dayMinutes = 0;
      (schedule || []).forEach(function (r) {
        const hasActual = String(r.as || '').trim()
          || String(r.ae || '').trim()
          || String(r.pj || '').trim()
          || String(r.at || '').trim();
        if (!hasActual) return;

        const minutes = actualDurationMinutes_(r.as, r.ae);
        if (minutes === null) {
          b.incompleteRows++;
          return;
        }
        dayMinutes += minutes;
        const pj = String(r.pj || '').trim() || 'PJ未設定';
        b.pjMinutes[pj] = (b.pjMinutes[pj] || 0) + minutes;
      });
      if (dayMinutes > 0) {
        b.actualDates[date] = true;
        b.actualMinutes += dayMinutes;
      }
    });
  }

  const logSh = getSh_(SHEET_LOG, H_LOG);
  const logLast = logSh.getLastRow();
  if (logLast >= 2) {
    const logVals = logSh.getRange(2, 1, logLast - 1, H_LOG.length).getValues();
    logVals.forEach(function (v) {
      const action = String(v[H_LOG.indexOf('action')] || '');
      if (action !== 'save' && action !== 'submit') return;
      const detail = String(v[H_LOG.indexOf('detail')] || '');
      const m = detail.match(/^(\d{4}-\d{2}-\d{2})/);
      if (!m || m[1].slice(0, 7) !== month) return;
      const b = getBucket_(v[H_LOG.indexOf('userId')], v[H_LOG.indexOf('name')]);
      if (!b) return;
      if (action === 'save') b.savedDates[m[1]] = true;
      if (action === 'submit') b.submittedDates[m[1]] = true;
    });
  }

  const rows = Object.keys(buckets).map(function (userId) {
    const b = buckets[userId];
    const savedDateList = Object.keys(b.savedDates);
    const submittedDateList = Object.keys(b.submittedDates);
    const savedDays = savedDateList.length;
    const submittedDays = submittedDateList.length;
    const unsubmittedDays = savedDateList.filter(function (date) {
      return !b.submittedDates[date];
    }).length;
    const pjs = Object.keys(b.pjMinutes).map(function (code) {
      return { code: code, minutes: b.pjMinutes[code] };
    }).sort(function (a, z) { return z.minutes - a.minutes; });
    return {
      userId: b.userId,
      name: b.name || b.userId,
      active: b.active,
      savedDays: savedDays,
      submittedDays: submittedDays,
      unsubmittedDays: unsubmittedDays,
      actualDays: Object.keys(b.actualDates).length,
      actualMinutes: b.actualMinutes,
      incompleteRows: b.incompleteRows,
      pjs: pjs
    };
  }).filter(function (r) {
    return r.active || r.savedDays || r.submittedDays || r.actualMinutes || r.incompleteRows;
  }).sort(function (a, z) {
    if (a.active !== z.active) return a.active ? -1 : 1;
    return a.userId.localeCompare(z.userId);
  });

  const totals = rows.reduce(function (t, r) {
    t.savedDays += r.savedDays;
    t.submittedDays += r.submittedDays;
    t.unsubmittedDays += r.unsubmittedDays;
    t.actualMinutes += r.actualMinutes;
    t.incompleteRows += r.incompleteRows;
    if (r.active) t.activeUsers++;
    return t;
  }, {
    users: rows.length,
    activeUsers: 0,
    savedDays: 0,
    submittedDays: 0,
    unsubmittedDays: 0,
    actualMinutes: 0,
    incompleteRows: 0
  });

  return {
    month: month,
    generatedAt: Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'),
    totals: totals,
    rows: rows
  };
}

/**
 * 配布URLのベースを登録する。/a/{domain}/ を含むURLでも汎用形式へ正規化する。
 * 管理APIなので、必ずadminAuth_を先頭で通す。
 */
function adminSetBaseUrl(nonce, url) {
  adminAuth_(nonce);
  url = String(url || '').trim().replace(/[?#].*$/, '').replace(/\/+$/, '');

  const props = PropertiesService.getScriptProperties();
  if (!url) {
    props.deleteProperty('webAppUrl');
    const fallback = webAppUrl_();
    if (fallback) syncUserUrls_(fallback);
    const autoRes = adminGetAll(nonce);
    autoRes.normalized = false;
    autoRes.autoDetected = true;
    return autoRes;
  }
  if (/\/dev$/.test(url)) {
    throw new Error('/dev で終わるURLは開発用です。編集権限のある人しか開けません。\n'
      + '[デプロイ]→[デプロイを管理]に表示される /exec で終わるURLを貼ってください。');
  }

  const normalized = normalizeBaseUrl_(url);
  if (!isValidBaseUrl_(normalized)) {
    throw new Error('https://script.google.com/.../exec の形式で入力してください。');
  }
  props.setProperty('webAppUrl', normalized);
  syncUserUrls_(normalized);

  const res = adminGetAll(nonce);
  res.normalized = normalized !== url;
  return res;
}

/** ユーザーの追加・更新。userId が空なら新規 */
function adminSaveUser(nonce, u) {
  adminAuth_(nonce);
  if (!String(u.name || '').trim()) throw new Error('氏名は必須です。');
  const sh = getSh_(SHEET_USER, H_USER);
  const users = readUsers_();

  if (u.userId) {
    const t = users.filter(function (x) { return x.userId === u.userId; })[0];
    if (!t) throw new Error('対象のユーザーが見つかりません。');
    sh.getRange(t.row, 2, 1, 4).setValues([[u.name, u.email || '', t.token, String(u.pin || '')]]);
    sh.getRange(t.row, H_USER.indexOf('active') + 1).setValue(u.active === true);
    return adminGetAll(nonce);
  }

  // 新規:既存の最大番号 +1(削除済みがあっても衝突しない)
  let max = 0;
  users.forEach(function (x) {
    const n = parseInt(String(x.userId).replace(/\D/g, ''), 10);
    if (n > max) max = n;
  });
  const userId = 'u' + ('000' + (max + 1)).slice(-3);
  const token = Utilities.getUuid().replace(/-/g, '');
  const base = webAppUrl_();
  sh.appendRow([userId, u.name, u.email || '', token, String(u.pin || ''), u.active !== false,
                base ? base + '?t=' + token : '',
                Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'), '']);
  return adminGetAll(nonce);
}

/** トークン再発行(古いURLは即座に無効になる) */
function adminReissueToken(nonce, userId) {
  adminAuth_(nonce);
  const sh = getSh_(SHEET_USER, H_USER);
  const t = readUsers_().filter(function (x) { return x.userId === userId; })[0];
  if (!t) throw new Error('対象のユーザーが見つかりません。');
  const token = Utilities.getUuid().replace(/-/g, '');
  const base = webAppUrl_();
  if (t.token) CacheService.getScriptCache().remove('auth_row_' + t.token);
  sh.getRange(t.row, H_USER.indexOf('token') + 1).setValue(token);
  sh.getRange(t.row, H_USER.indexOf('url') + 1).setValue(base ? base + '?t=' + token : '');
  return adminGetAll(nonce);
}

/** PJの追加・更新。code はキーなので変更不可(過去の日報が参照しているため) */
function adminSavePj(nonce, pj) {
  adminAuth_(nonce);
  const code = String(pj.code || '').trim();
  if (!code) throw new Error('PJコードは必須です。');
  if (!String(pj.name || '').trim()) throw new Error('PJ名は必須です。');
  const sh = getSh_(SHEET_PJ, H_PJ);
  const t = readPjs_().filter(function (x) { return x.code === code; })[0];
  if (t) {
    sh.getRange(t.row, 1, 1, H_PJ.length).setValues([[code, pj.name, pj.active !== false, pj.note || '']]);
  } else {
    sh.appendRow([code, pj.name, pj.active !== false, pj.note || '']);
  }
  return adminGetAll(nonce);
}

function activePjs_() {
  return readPjs_().filter(function (p) { return p.active; })
    .map(function (p) { return { code: p.code, name: p.name }; });
}

/** 利用者側から呼ぶ:有効なPJの一覧 */
function getPjs(token, pin) {
  auth_(token, pin);
  return activePjs_();
}

function activeUserRow_() {
  const sh = SpreadsheetApp.getActive().getActiveSheet();
  if (sh.getName() !== SHEET_USER) throw new Error('「' + SHEET_USER + '」シートで対象の行を選択してから実行してください。');
  const r = sh.getActiveRange().getRow();
  if (r < 2) throw new Error('データ行を選択してください。');
  return { sh: sh, row: r };
}

/** 管理者が選択行のユーザーとして動作確認する */
function openDialog() {
  const ui = SpreadsheetApp.getUi();  // ウェブアプリからの直接呼び出しを先に遮断
  const a = activeUserRow_();
  const v = a.sh.getRange(a.row, 1, 1, H_USER.length).getValues()[0];
  const t = HtmlService.createTemplateFromFile('index');
  t.token = v[3];
  ui.showModalDialog(
    t.evaluate().setWidth(1100).setHeight(720), '業務日報(' + v[1] + ' さんとして表示)');
}

function showWebAppUrl() {
  const ui = SpreadsheetApp.getUi();  // ウェブアプリからの直接呼び出しを先に遮断
  const url = webAppUrl_();
  ui.alert(url
    ? 'ウェブアプリURL(この素のURLは配布しません):\n\n' + url
      + '\n\n配布するのは「ユーザー」シートの url 列にある個人別URLです。'
    : 'まだデプロイされていません。[デプロイ]→[新しいデプロイ]→ ウェブアプリで公開してください。');
}

/* ================= 認証 ================= */

function doGet(e) {
  // HTMLを返すだけにする。シート確認はsetupと認証後のAPI側で行う。
  const t = HtmlService.createTemplateFromFile('index');
  t.token = (e && e.parameter && e.parameter.t) ? String(e.parameter.t) : '';
  return t.evaluate()
    .setTitle('業務日報')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function checkAuthRow_(v, row, pin) {
  if (v[5] !== true && String(v[5]).toUpperCase() !== 'TRUE') throw new Error('DISABLED');
  const needPin = String(v[4] || '').trim();
  if (needPin) {
    if (!String(pin || '').trim()) throw new Error('PIN_REQUIRED');
    if (String(pin).trim() !== needPin) throw new Error('PIN_WRONG');
  }
  return { row: row, userId: String(v[0]), name: String(v[1]), email: String(v[2]) };
}

/**
 * トークン(+PIN)からユーザーを特定する。
 * CacheServiceには認証結果ではなく「候補行」だけを保存し、毎回その行のtoken/active/PINを再確認する。
 * そのため、ユーザー無効化・PIN変更・トークン再発行は即時に反映される。
 */
function auth_(token, pin) {
  token = String(token || '').trim();
  if (!token) throw new Error('NO_TOKEN');
  const sh = getSh_(SHEET_USER, H_USER);
  const last = sh.getLastRow();
  if (last < 2) throw new Error('BAD_TOKEN');

  const cache = CacheService.getScriptCache();
  const key = 'auth_row_' + token;
  const hinted = Number(cache.get(key) || 0);
  if (hinted >= 2 && hinted <= last) {
    const v = sh.getRange(hinted, 1, 1, H_USER.length).getValues()[0];
    if (String(v[3]).trim() === token) return checkAuthRow_(v, hinted, pin);
    cache.remove(key);
  }

  const vals = sh.getRange(2, 1, last - 1, H_USER.length).getValues();
  for (let i = 0; i < vals.length; i++) {
    if (String(vals[i][3]).trim() !== token) continue;
    const row = i + 2;
    cache.put(key, String(row), 21600);
    return checkAuthRow_(vals[i], row, pin);
  }
  throw new Error('BAD_TOKEN');
}

function log_(u, action, detail) {
  try {
    getSh_(SHEET_LOG, H_LOG).appendRow([
      Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss'),
      u.userId, u.name, action, detail || ''
    ]);
  } catch (e) { /* ログ失敗で本体を止めない */ }
}

function touchOpen_(u) {
  getSh_(SHEET_USER, H_USER).getRange(u.row, H_USER.indexOf('lastAccess') + 1)
    .setValue(Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'));
  log_(u, 'open', '');
}

function today_() {
  return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd');
}

/** 旧クライアント互換。新しい画面はgetInitialDataを使う。 */
function getSession(token, pin) {
  const u = auth_(token, pin);
  touchOpen_(u);
  return { userId: u.userId, name: u.name, email: u.email,
           today: today_(), settings: settingsForClient_(readSettingsRaw_(u.userId), false) };
}

/**
 * 初回表示用。認証・設定・PJ・当日日報を1回のgoogle.script.runで返す。
 * 利用者向け公開関数なので、必ず第1・第2引数を(token, pin)とし、先頭でauth_を通す。
 */
function getInitialData(token, pin, dateStr) {
  const u = auth_(token, pin);
  const today = today_();
  const date = dateStr ? validDate_(dateStr) : today;
  const rawSettings = readSettingsRaw_(u.userId);
  const report = readReport_(u.userId, date, rawSettings.nextRule);
  const settings = settingsForClient_(rawSettings, false);
  const pjs = activePjs_();
  touchOpen_(u);
  return {
    session: { userId: u.userId, name: u.name, email: u.email, today: today, settings: settings },
    pjs: pjs,
    report: report
  };
}

/* ================= 設定(ユーザー別・設定シート) ================= */

function readSettingsRaw_(userId) {
  const sh = getSh_(SHEET_CONFIG, H_CONFIG);
  const last = sh.getLastRow();
  const o = { _row: 0, nextRule: 'business', folderId: '', autoSubmit: false };
  if (last < 2) return o;
  const vals = sh.getRange(2, 1, last - 1, H_CONFIG.length).getValues();
  for (let i = 0; i < vals.length; i++) {
    if (String(vals[i][0]) !== userId) continue;
    o._row = i + 2;
    o.nextRule = String(vals[i][1] || 'business') === 'next' ? 'next' : 'business';
    o.folderId = String(vals[i][2] || '');
    o.autoSubmit = isTrue_(vals[i][3]);
    break;
  }
  return o;
}

function folderCacheKey_(folderId) { return 'folder_name_' + folderId; }

function cacheFolderName_(folderId, name) {
  if (folderId && name) CacheService.getScriptCache().put(folderCacheKey_(folderId), String(name), 21600);
}

/**
 * resolveFolder=falseならDrive APIを呼ばない。初回表示や日付計算ではこちらを使う。
 * resolveFolder=trueは設定パネルを開いた時など、名称が本当に必要な場面だけに限定する。
 */
function settingsForClient_(o, resolveFolder, knownName) {
  const s = {
    nextRule: o.nextRule === 'next' ? 'next' : 'business',
    folderId: String(o.folderId || ''),
    folderName: '',
    folderUrl: '',
    autoSubmit: o.autoSubmit === true
  };
  if (!s.folderId) return s;

  s.folderUrl = 'https://drive.google.com/drive/folders/' + s.folderId;
  let name = knownName || CacheService.getScriptCache().get(folderCacheKey_(s.folderId)) || '';
  if (!name && resolveFolder) {
    try {
      name = DriveApp.getFolderById(s.folderId).getName();
      cacheFolderName_(s.folderId, name);
    } catch (e) {
      name = '⚠ アクセスできません';
    }
  }
  s.folderName = name;
  return s;
}

function readSettings_(userId) {
  return settingsForClient_(readSettingsRaw_(userId), false);
}

function writeSettings_(userId, o, knownFolderName) {
  const sh = getSh_(SHEET_CONFIG, H_CONFIG);
  const row = [userId, o.nextRule === 'next' ? 'next' : 'business', String(o.folderId || ''), o.autoSubmit === true];
  if (o._row) sh.getRange(o._row, 1, 1, H_CONFIG.length).setValues([row]);
  else sh.appendRow(row);
  return settingsForClient_(o, false, knownFolderName);
}

function getSettings(token, pin) {
  const u = auth_(token, pin);
  return settingsForClient_(readSettingsRaw_(u.userId), true);
}

function setNextRule(token, pin, rule) {
  const u = auth_(token, pin);
  const s = readSettingsRaw_(u.userId);
  s.nextRule = (rule === 'next') ? 'next' : 'business';
  return writeSettings_(u.userId, s);
}

/** 既定ルール変更と転記先再計算を、1回のgoogle.script.runで行う。 */
function setNextRuleAndCalc(token, pin, rule, dateStr) {
  const u = auth_(token, pin);
  const date = validDate_(dateStr);
  const s = readSettingsRaw_(u.userId);
  s.nextRule = (rule === 'next') ? 'next' : 'business';
  const settings = writeSettings_(u.userId, s);
  return {
    settings: settings,
    nextDate: defaultNextDateByRule_(s.nextRule, date)
  };
}

function setFolder(token, pin, input) {
  const u = auth_(token, pin);
  const id = parseFolderId_(input);
  if (!id) throw new Error('フォルダのURLまたはIDとして認識できませんでした。');
  let folderName = '';
  try { folderName = DriveApp.getFolderById(id).getName(); }
  catch (e) { throw new Error('そのフォルダにアクセスできません。IDを確認してください。'); }
  cacheFolderName_(id, folderName);
  const s = readSettingsRaw_(u.userId);
  s.folderId = id;
  const res = writeSettings_(u.userId, s, folderName);
  log_(u, 'setFolder', folderName);
  return res;
}

function clearFolder(token, pin) {
  const u = auth_(token, pin);
  const s = readSettingsRaw_(u.userId);
  s.folderId = '';
  return writeSettings_(u.userId, s);
}

function setAutoSubmit(token, pin, on) {
  const u = auth_(token, pin);
  const s = readSettingsRaw_(u.userId);
  s.autoSubmit = !!on;
  return writeSettings_(u.userId, s);
}

function parseFolderId_(s) {
  s = String(s || '').trim();
  let m = s.match(/\/folders\/([-\w]+)/);
  if (m) return m[1];
  m = s.match(/[?&]id=([-\w]+)/);
  if (m) return m[1];
  if (/^[-\w]{15,}$/.test(s)) return s;
  return '';
}

/* ================= 日付ユーティリティ ================= */

function fmtDate_(v) {
  if (v instanceof Date) return Utilities.formatDate(v, Session.getScriptTimeZone(), 'yyyy-MM-dd');
  return String(v).trim();
}
function fmtDateTime_(v) {
  if (v instanceof Date) return Utilities.formatDate(v, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm');
  return String(v);
}
function shiftDate_(dateStr, days) {
  const p = dateStr.split('-').map(Number);
  const d = new Date(p[0], p[1] - 1, p[2]);
  d.setDate(d.getDate() + days);
  return Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy-MM-dd');
}
function mdw_(dateStr) {
  const p = dateStr.split('-').map(Number);
  const w = ['日','月','火','水','木','金','土'][new Date(p[0], p[1] - 1, p[2]).getDay()];
  return p[1] + '/' + p[2] + '(' + w + ')';
}
function parseJson_(v, fallback) {
  try { return v ? JSON.parse(v) : fallback; } catch (e) { return fallback; }
}


function validDate_(v, label) {
  const s = String(v || '').trim();
  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw new Error((label || '日付') + 'が正しくありません。');
  const p = s.split('-').map(Number);
  const d = new Date(p[0], p[1] - 1, p[2]);
  if (d.getFullYear() !== p[0] || d.getMonth() !== p[1] - 1 || d.getDate() !== p[2]) {
    throw new Error((label || '日付') + 'が正しくありません。');
  }
  return s;
}

/** 日本の祝日(取得できなければ空配列=土日のみ判定) */
function getJpHolidays_() {
  const cache = CacheService.getScriptCache();
  const hit = cache.get('jp_holidays');
  if (hit) return JSON.parse(hit);
  let list = [];
  try {
    const cal = CalendarApp.getCalendarById(HOLIDAY_CAL);
    if (cal) {
      const y = new Date().getFullYear();
      list = cal.getEvents(new Date(y - 1, 0, 1), new Date(y + 1, 11, 31)).map(function (e) {
        const d = e.isAllDayEvent() ? e.getAllDayStartDate() : e.getStartTime();
        return Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy-MM-dd');
      });
    }
  } catch (err) { list = []; }
  cache.put('jp_holidays', JSON.stringify(list), 21600);
  return list;
}

function isDayOff_(dateStr, holidays) {
  const p = dateStr.split('-').map(Number);
  const w = new Date(p[0], p[1] - 1, p[2]).getDay();
  if (w === 0 || w === 6) return true;
  return holidays.indexOf(dateStr) >= 0;
}

function defaultNextDateByRule_(nextRule, dateStr) {
  if (nextRule === 'next') return shiftDate_(dateStr, 1);
  const holidays = getJpHolidays_();
  let d = shiftDate_(dateStr, 1);
  for (let i = 0; i < 10 && isDayOff_(d, holidays); i++) d = shiftDate_(d, 1);
  return d;
}

function defaultNextDate_(userId, dateStr) {
  return defaultNextDateByRule_(readSettingsRaw_(userId).nextRule, dateStr);
}

function calcNextDate(token, pin, dateStr) {
  const u = auth_(token, pin);
  const s = readSettingsRaw_(u.userId);
  return defaultNextDateByRule_(s.nextRule, validDate_(dateStr));
}

/* ================= 日報の読み書き ================= */

function emptySchedule_(n) {
  const rows = [];
  for (let i = 0; i < n; i++) rows.push({ ps: '', pe: '', pt: '', as: '', ae: '', at: '', pj: '' });
  return rows;
}

function emptyReport_(userId, dateStr, nextRule) {
  const rule = nextRule || readSettingsRaw_(userId).nextRule;
  return {
    date: dateStr, userId: userId,
    focus: ['', '', ''], schedule: emptySchedule_(DEFAULT_ROWS),
    unplanned: '', insights: '', tmrFocus: ['', '', ''], tmrPlan: '',
    carryover: '', memo: '', custom: [],
    nextDate: defaultNextDateByRule_(rule, dateStr), updatedAt: ''
  };
}

function reportRowKey_(userId, dateStr) {
  return 'report_row_' + userId + '_' + dateStr;
}

function rememberReportRow_(userId, dateStr, row) {
  if (row >= 2) CacheService.getScriptCache().put(reportRowKey_(userId, dateStr), String(row), 21600);
}

/** キャッシュするのは候補行だけ。日付とuserIdは毎回シート上の値で再確認する。 */
function findRow_(sh, userId, dateStr) {
  const last = sh.getLastRow();
  if (last < 2) return 0;
  const cache = CacheService.getScriptCache();
  const key = reportRowKey_(userId, dateStr);
  const hinted = Number(cache.get(key) || 0);
  if (hinted >= 2 && hinted <= last) {
    const v = sh.getRange(hinted, 1, 1, 2).getValues()[0];
    if (fmtDate_(v[0]) === dateStr && String(v[1]).trim() === userId) return hinted;
    cache.remove(key);
  }

  const vals = sh.getRange(2, 1, last - 1, 2).getValues();
  for (let i = 0; i < vals.length; i++) {
    if (fmtDate_(vals[i][0]) === dateStr && String(vals[i][1]).trim() === userId) {
      const row = i + 2;
      rememberReportRow_(userId, dateStr, row);
      return row;
    }
  }
  return 0;
}

function rowToObj_(row) {
  return {
    date: fmtDate_(row[0]),
    userId: String(row[1]),
    focus: parseJson_(row[2], ['', '', '']),
    schedule: parseJson_(row[3], []),
    unplanned: row[4] || '', insights: row[5] || '',
    tmrFocus: parseJson_(row[6], ['', '', '']),
    tmrPlan: row[7] || '', carryover: row[8] || '', memo: row[9] || '',
    custom: parseJson_(row[10], []),
    updatedAt: row[11] ? fmtDateTime_(row[11]) : '',
    nextDate: row[12] ? fmtDate_(row[12]) : ''
  };
}

function objToRow_(d, nextRule) {
  return [
    d.date, d.userId,
    JSON.stringify(d.focus), JSON.stringify(d.schedule),
    d.unplanned, d.insights,
    JSON.stringify(d.tmrFocus), d.tmrPlan, d.carryover, d.memo,
    JSON.stringify(d.custom),
    Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'),
    d.nextDate || defaultNextDateByRule_(nextRule || readSettingsRaw_(d.userId).nextRule, d.date)
  ];
}

function writeReportRow_(sh, row, d, nextRule) {
  sh.getRange(row, 1, 1, H_REPORT.length).setValues([objToRow_(d, nextRule)]);
  rememberReportRow_(d.userId, d.date, row);
}

function appendReportRow_(sh, d, nextRule) {
  const row = sh.getLastRow() + 1;
  writeReportRow_(sh, row, d, nextRule);
  return row;
}

function normalizeFocus_(arr) {
  const a = (arr || []).slice(0, 3);
  while (a.length < 3) a.push('');
  return a;
}

function readReport_(userId, dateStr, nextRule) {
  const sh = getSh_(SHEET_REPORT, H_REPORT);
  const r = findRow_(sh, userId, dateStr);
  if (!r) return emptyReport_(userId, dateStr, nextRule);
  const obj = rowToObj_(sh.getRange(r, 1, 1, H_REPORT.length).getValues()[0]);
  if (!obj.schedule.length) obj.schedule = emptySchedule_(DEFAULT_ROWS);
  if (!obj.nextDate) {
    const rule = nextRule || readSettingsRaw_(userId).nextRule;
    obj.nextDate = defaultNextDateByRule_(rule, dateStr);
  }
  return obj;
}

/** クライアントから呼ぶ:指定日の日報を取得(他人のIDは指定できない) */
function getReport(token, pin, dateStr) {
  const u = auth_(token, pin);
  const date = validDate_(dateStr);
  const s = readSettingsRaw_(u.userId);
  return readReport_(u.userId, date, s.nextRule);
}

/** クライアントから呼ぶ:保存 + 明日の重点を転記先へ */
function saveReport(token, pin, data) {
  const u = auth_(token, pin);
  if (!data || typeof data !== 'object') throw new Error('保存データが正しくありません。');
  data.date = validDate_(data.date, '日報の日付');
  if (data.nextDate) data.nextDate = validDate_(data.nextDate, '転記先の日付');
  const lock = LockService.getScriptLock();
  lock.waitLock(20000);
  try {
    const sh = getSh_(SHEET_REPORT, H_REPORT);
    const nextRule = readSettingsRaw_(u.userId).nextRule;
    data.userId = u.userId;   // クライアントの申告は信用しない

    const r = findRow_(sh, u.userId, data.date);
    if (r) writeReportRow_(sh, r, data, nextRule);
    else appendReportRow_(sh, data, nextRule);

    // ── 明日の重点 → 指定した転記先の「今日の重点」へ
    const carried = (data.tmrFocus || []).filter(function (s) { return String(s).trim(); });
    let carriedTo = '';
    const nextDate = data.nextDate || defaultNextDateByRule_(nextRule, data.date);
    if (carried.length && nextDate && nextDate !== data.date) {
      const nr = findRow_(sh, u.userId, nextDate);
      if (!nr) {
        const nd = emptyReport_(u.userId, nextDate, nextRule);
        nd.focus = normalizeFocus_(data.tmrFocus);
        appendReportRow_(sh, nd, nextRule);
        carriedTo = nextDate;
      } else {
        const nd = rowToObj_(sh.getRange(nr, 1, 1, H_REPORT.length).getValues()[0]);
        if (!(nd.focus || []).some(function (s) { return String(s).trim(); })) {
          nd.focus = normalizeFocus_(data.tmrFocus);
          writeReportRow_(sh, nr, nd, nextRule);
          carriedTo = nextDate;
        }
      }
    }
    log_(u, 'save', data.date);
    return {
      ok: true,
      updatedAt: Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'),
      carriedTo: carriedTo
    };
  } finally {
    lock.releaseLock();
  }
}

/* ================= PDF ================= */

function esc_(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/\n/g, '<br>');
}

function buildPdfBlob_(u, dateStr) {
  const d = readReport_(u.userId, dateStr);
  const now = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm');
  const name = '日報_' + dateStr + '_' + (u.name || u.userId);
  const html = buildPdfHtml_(d, u, now);
  return Utilities.newBlob(html, MimeType.HTML, name + '.html')
    .getAs(MimeType.PDF).setName(name + '.pdf');
}

/** クライアントから呼ぶ:PDFをダウンロード */
function exportPdf(token, pin, dateStr) {
  const u = auth_(token, pin);
  dateStr = validDate_(dateStr);
  const blob = buildPdfBlob_(u, dateStr);
  log_(u, 'pdf', dateStr);
  return { filename: blob.getName(), base64: Utilities.base64Encode(blob.getBytes()) };
}

/** クライアントから呼ぶ:設定済みフォルダへ提出(同名は差し替え) */
function submitPdf(token, pin, dateStr) {
  const u = auth_(token, pin);
  dateStr = validDate_(dateStr);
  const s = readSettingsRaw_(u.userId);
  if (!s.folderId) throw new Error('提出先フォルダが未設定です。');
  let folder;
  try { folder = DriveApp.getFolderById(s.folderId); }
  catch (e) { throw new Error('提出先フォルダにアクセスできません。設定し直してください。'); }

  const blob = buildPdfBlob_(u, dateStr);
  const it = folder.getFilesByName(blob.getName());
  while (it.hasNext()) it.next().setTrashed(true);
  const f = folder.createFile(blob);
  log_(u, 'submit', dateStr + ' → ' + folder.getName());
  return { name: blob.getName(), url: f.getUrl(), folder: folder.getName() };
}

function buildPdfHtml_(d, u, now) {
  const rows = (d.schedule || []).filter(function (r) {
    return r.ps || r.pe || r.pt || r.as || r.ae || r.at || r.pj;
  });
  const schedHtml = rows.length ? rows.map(function (r) {
    return '<tr>' +
      '<td class="t">' + esc_(r.ps) + '</td><td class="t">' + esc_(r.pe) + '</td><td>' + esc_(r.pt) + '</td>' +
      '<td class="t">' + esc_(r.as) + '</td><td class="t">' + esc_(r.ae) + '</td>' +
      '<td class="pj">' + esc_(r.pj) + '</td><td>' + esc_(r.at) + '</td>' +
      '</tr>';
  }).join('') : '<tr><td colspan="7" class="empty">―</td></tr>';

  const fx = function (arr) {
    return normalizeFocus_(arr).map(function (f, i) {
      return '<div class="fx"><span class="no">' + (i + 1) + '</span>' + (esc_(f) || '&nbsp;') + '</div>';
    }).join('');
  };
  const customHtml = (d.custom || []).map(function (c) {
    return '<div class="box"><div class="h">' + esc_(c.name) + '</div><div class="b">' + (esc_(c.value) || '&nbsp;') + '</div></div>';
  }).join('');
  const nextLabel = d.nextDate ? '(→ ' + esc_(mdw_(d.nextDate)) + ')' : '';
  const who = esc_(u.name) + (u.email ? ' ' + esc_(u.email) : '');

  return '<!DOCTYPE html><html><head><meta charset="utf-8"><style>' +
    '@page { size: A4 portrait; margin: 10mm; }' +
    'body { font-family: "Noto Sans JP", "MS Gothic", sans-serif; font-size: 8.5pt; color: #111; margin: 0; }' +
    '.head { display: table; width: 100%; border-bottom: 1.5pt solid #111; padding-bottom: 3pt; margin-bottom: 6pt; }' +
    '.head .l { display: table-cell; font-size: 15pt; font-weight: bold; }' +
    '.head .r { display: table-cell; text-align: right; vertical-align: bottom; font-size: 7.5pt; color: #555; }' +
    '.box { border: 0.5pt solid #999; margin-bottom: 4pt; }' +
    '.box .h { background: #eee; padding: 1.5pt 4pt; font-weight: bold; font-size: 8pt; border-bottom: 0.5pt solid #999; }' +
    '.box .b { padding: 3pt 4pt; min-height: 24pt; line-height: 1.4; }' +
    '.fx { padding: 1.5pt 0; border-bottom: 0.25pt dotted #ccc; }' +
    '.no { display: inline-block; width: 12pt; font-weight: bold; color: #666; }' +
    'table { width: 100%; border-collapse: collapse; }' +
    'th, td { border: 0.5pt solid #999; padding: 1.5pt 3pt; font-size: 8pt; }' +
    'th { background: #eee; font-size: 7.5pt; }' +
    'td.t { text-align: center; width: 34pt; }' +
    'td.pj { text-align: center; width: 46pt; font-size: 7.5pt; }' +
    'td.empty { text-align: center; color: #999; }' +
    '.cols { display: table; width: 100%; border-spacing: 4pt 0; margin-left: -4pt; }' +
    '.col { display: table-cell; width: 50%; vertical-align: top; }' +
    '</style></head><body>' +
    '<div class="head"><div class="l">業務日報 ' + esc_(d.date) + '</div>' +
    '<div class="r">' + who + '<br>出力: ' + esc_(now) + '</div></div>' +
    '<div class="box"><div class="h">今日の重点(3つまで)</div><div class="b">' + fx(d.focus) + '</div></div>' +
    '<div class="box"><div class="h">スケジュール</div><div class="b" style="padding:0">' +
    '<table><tr><th colspan="3" style="background:#e3e8ef">予定</th><th colspan="4" style="background:#e8efe3">実績</th></tr>' +
    '<tr><th>開始</th><th>終了</th><th>業務内容</th><th>開始</th><th>終了</th><th>PJ</th><th>業務内容</th></tr>' +
    schedHtml + '</table></div></div>' +
    '<div class="cols">' +
    '<div class="col"><div class="box"><div class="h">予定外の業務</div><div class="b">' + (esc_(d.unplanned) || '&nbsp;') + '</div></div></div>' +
    '<div class="col"><div class="box"><div class="h">気づき</div><div class="b">' + (esc_(d.insights) || '&nbsp;') + '</div></div></div>' +
    '</div>' +
    '<div class="cols">' +
    '<div class="col"><div class="box"><div class="h">明日の重点' + nextLabel + '</div><div class="b">' + fx(d.tmrFocus) + '</div></div></div>' +
    '<div class="col"><div class="box"><div class="h">明日の予定' + nextLabel + '<span style="font-weight:400"> 主なもの</span></div><div class="b">' + (esc_(d.tmrPlan) || '&nbsp;') + '</div></div></div>' +
    '</div>' +
    '<div class="cols">' +
    '<div class="col"><div class="box"><div class="h">持ち越し</div><div class="b">' + (esc_(d.carryover) || '&nbsp;') + '</div></div></div>' +
    '<div class="col"><div class="box"><div class="h">メモ</div><div class="b">' + (esc_(d.memo) || '&nbsp;') + '</div></div></div>' +
    '</div>' +
    customHtml +
    '</body></html>';
}

SHA-256: ffddad2ce979caff9b2ae539229bdded68819e21e5beb264fad5eb4bd895655f

2. 利用者画面

Apps ScriptでHTMLファイルを追加し、名前を必ず index にしてから貼り付けます。

index.html 923行 / 42.4 KB ファイルを保存
コード全文を表示
<!DOCTYPE html>
<html lang="ja">
<head>
<base target="_top">
<meta charset="utf-8">
<style>
  :root {
    --paper:#EBEEF2; --card:#fff;
    --ink:#16283C; --soft:#5A6B80; --rule:#D5DBE4;
    --indigo:#2A4A7B; --indigo-tint:#EAF0F9;
    --seal:#BE3A2B; --seal-tint:#FBEDEA;
    --grid:rgba(42,74,123,.055);
    --shadow:0 1px 2px rgba(22,40,60,.05), 0 8px 24px -16px rgba(22,40,60,.25);
  }
  * { box-sizing:border-box; }
  body {
    font-family:system-ui, "Hiragino Kaku Gothic ProN", "Yu Gothic UI", "Meiryo", sans-serif;
    margin:0; color:var(--ink); font-size:15.5px; line-height:1.65;
    background-color:var(--paper);
    background-image:linear-gradient(var(--grid) 1px, transparent 1px),
                     linear-gradient(90deg, var(--grid) 1px, transparent 1px);
    background-size:24px 24px;
  }
  .wrap { max-width:1120px; margin:0 auto; padding:0 16px 44px; }

  /* ── 起動中 ── */
  #boot { max-width:420px; margin:96px auto; text-align:center; color:var(--soft); font-size:15px; }
  #boot .spin { width:28px; height:28px; margin:0 auto 14px; border-radius:50%;
                border:2px solid var(--rule); border-top-color:var(--indigo);
                animation:spin .8s linear infinite; }
  @keyframes spin { to { transform:rotate(360deg); } }

  /* ── 認証ゲート ── */
  #gate { max-width:420px; margin:72px auto; background:var(--card); border:1px solid var(--rule);
          border-radius:12px; padding:32px 28px; text-align:center; box-shadow:var(--shadow); }
  #gate h2 { margin:0; font-family:"Hiragino Mincho ProN", "Yu Mincho", serif; font-size:20px; font-weight:600; }
  #gate p { color:var(--soft); font-size:14px; margin:11px 0 20px; }
  #gate input { font:inherit; font-size:24px; letter-spacing:8px; text-align:center; width:190px;
                padding:11px; border:1px solid var(--rule); border-radius:8px; background:var(--paper); }
  #gate input:focus { outline:2px solid var(--indigo); outline-offset:1px; }
  #gateErr { color:var(--seal); font-size:13.5px; min-height:20px; margin-top:11px; }

  #pinBusy { display:none; align-items:center; justify-content:center; gap:10px;
             min-height:46px; margin:8px 0 4px; padding:10px 12px; color:var(--indigo);
             background:var(--indigo-tint); border:1px solid #cbd8ea; border-radius:9px;
             font-size:14px; font-weight:500; }
  #pinBusy .mini-spin { width:20px; height:20px; flex:none; border-radius:50%;
                        border:2px solid var(--rule); border-top-color:var(--indigo);
                        animation:spin .8s linear infinite; }
  #gate[aria-busy="true"] #pinInput { opacity:.65; }

  /* ── Google Apps Script 標準表示の案内 ── */
  .google-standard-note {
    color:#5f4b24; background:#fffaf0; border:1px solid #ead9b7; border-radius:9px;
    font-size:12.5px; line-height:1.6; text-align:left;
  }
  #boot .google-standard-note { margin:20px auto 0; padding:10px 12px; max-width:390px; }
  #gate .google-standard-note { margin:18px 0 0; padding:10px 12px; }
  .app-google-note { margin:0 0 14px; padding:0; overflow:hidden; }
  .app-google-note summary { cursor:pointer; padding:9px 12px; color:#6f531d; font-size:12.5px;
                             font-weight:700; list-style-position:inside; }
  .app-google-note div { padding:0 12px 11px; }

  /* ── ヘッダ ── */
  header { position:sticky; top:0; z-index:5; margin:0 -16px 16px; padding:0 16px;
           background:rgba(235,238,242,.97);
           border-bottom:1px solid var(--rule); }
  .bar { display:flex; flex-wrap:wrap; gap:9px; align-items:center; padding:11px 0 9px; }
  h1 { font-family:"Hiragino Mincho ProN", "Yu Mincho", serif; font-size:20px; font-weight:600; margin:0;
       letter-spacing:.08em; }
  .who { font-size:13.5px; color:var(--soft); border-left:1px solid var(--rule); padding-left:10px; }
  .spacer { flex:1; }
  #status { font-size:12.5px; color:var(--soft); font-variant-numeric:tabular-nums; }
  #status.dirty { color:var(--seal); }

  button { font:inherit; font-size:14.5px; padding:7px 14px; border:1px solid var(--rule);
           background:var(--card); color:var(--ink); border-radius:7px; cursor:pointer;
           transition:background .12s, border-color .12s, transform .06s; }
  button:hover { background:var(--indigo-tint); border-color:#b9c7dd; }
  button:active { transform:translateY(1px); }
  button:focus-visible { outline:2px solid var(--indigo); outline-offset:2px; }
  button.primary { background:var(--indigo); color:#fff; border-color:var(--indigo); }
  button.primary:hover { background:#35598f; }
  button.seal { color:var(--seal); border-color:#e3b7b1; font-weight:500; }
  button.seal:hover { background:var(--seal); color:#fff; border-color:var(--seal); }
  button:disabled { opacity:.4; cursor:default; }
  button:disabled:hover { background:var(--card); border-color:var(--rule); }

  /* ── 日付バー ── */
  .datebar { display:flex; align-items:center; gap:11px; padding:3px 0 13px; }
  .nav { width:38px; padding:7px 0; text-align:center; font-size:16px; }
  .today { font-size:13.5px; padding:6px 11px; }
  .stamp { font-family:"Hiragino Mincho ProN", "Yu Mincho", serif; font-weight:600; font-size:21px;
           color:var(--seal); border:2px solid var(--seal); border-radius:5px;
           padding:5px 14px; background:rgba(255,255,255,.65); letter-spacing:.03em;
           box-shadow:inset 0 0 0 1px #fff, inset 0 0 0 3px rgba(190,58,43,.16);
           transform:rotate(-1.2deg); white-space:nowrap; }
  .datebar input[type=date] { font:inherit; font-size:13.5px; padding:6px 7px; color:var(--soft);
                              border:1px solid var(--rule); border-radius:6px; background:var(--card); }

  /* ── セクション ── */
  section { background:var(--card); border:1px solid var(--rule); border-radius:10px;
            margin-bottom:14px; box-shadow:var(--shadow); overflow:hidden; }
  section > h2 { font-size:14px; margin:0; padding:10px 15px; font-weight:700; letter-spacing:.06em;
                 color:var(--ink); border-bottom:1px solid var(--rule); background:#fbfcfd;
                 display:flex; align-items:center; gap:8px; }
  section > h2::before { content:""; width:3px; height:13px; border-radius:2px;
                         background:var(--indigo); flex:none; }
  section > h2 .note { font-weight:400; color:var(--soft); font-size:12.5px; letter-spacing:0; }
  section > h2 .spacer { flex:1; }
  section > h2 input[type=date], section > h2 select {
    font:inherit; font-size:12.5px; font-weight:400; padding:3px 6px; color:var(--ink);
    border:1px solid var(--rule); border-radius:5px; background:var(--card); }
  section > h2 select { color:var(--soft); }
  .body { padding:14px 15px; }

  /* ── 設定パネル ── */
  .set-row { display:flex; align-items:center; gap:8px; margin-bottom:7px; }
  .set-row > label:first-child { width:120px; font-size:13.5px; color:var(--soft); flex:none; }
  .set-row input[type=text] { flex:1; font:inherit; font-size:15px; padding:8px 9px;
                              border:1px solid var(--rule); border-radius:6px; background:var(--paper); }
  .set-row input[type=text]:focus { outline:2px solid var(--indigo); outline-offset:-1px; background:#fff; }
  .note { color:var(--soft); font-size:12.5px; }
  code { background:var(--indigo-tint); padding:1px 5px; border-radius:3px; font-size:12px; }

  textarea { width:100%; font:inherit; font-size:15.5px; padding:10px; border:1px solid var(--rule);
             border-radius:7px; resize:vertical; min-height:74px; background:var(--paper);
             line-height:1.7; }
  textarea:focus { outline:2px solid var(--indigo); outline-offset:-1px; background:#fff; }

  /* ── 重点 ── */
  .focus-row { display:flex; align-items:center; gap:10px; margin-bottom:7px; }
  .focus-row:last-child { margin-bottom:0; }
  .focus-row span { width:22px; height:22px; flex:none; border-radius:50%; font-size:12px;
                    display:grid; place-items:center; color:#fff; background:var(--indigo);
                    font-weight:500; }
  .focus-row input { flex:1; font:inherit; font-size:15.5px; padding:9px 10px;
                     border:1px solid var(--rule); border-radius:7px; background:var(--paper); }
  .focus-row input:focus { outline:2px solid var(--indigo); outline-offset:-1px; background:#fff; }

  /* ── スケジュール ── */
  .schedule-scroll { overflow-x:auto; -webkit-overflow-scrolling:touch; }
  .schedule-scroll table { min-width:980px; }
  table { width:100%; border-collapse:collapse; }
  th, td { border:1px solid var(--rule); padding:0; }
  th { font-size:12.5px; padding:6px; font-weight:500; color:var(--soft); background:#fbfcfd; }
  th.plan { background:var(--indigo-tint); color:var(--indigo); font-weight:700; letter-spacing:.1em; }
  th.act  { background:var(--seal-tint); color:var(--seal); font-weight:700; letter-spacing:.1em; }
  tbody tr:hover td { background:#fafbfd; }
  td input { width:100%; border:0; padding:8px; font:inherit; font-size:14.5px; background:transparent; }
  td input:focus { outline:2px solid var(--indigo); outline-offset:-2px; background:#fff; }
  td input[type=time] { font-family:ui-monospace, "SFMono-Regular", monospace; font-size:13.5px;
                        color:var(--soft); }
  td.time { width:82px; }
  td.del { width:38px; text-align:center; }
  td.pj { width:130px; }
  td.pj select { width:100%; border:0; padding:7px; font:inherit; font-size:13.5px;
                 background:transparent; cursor:pointer; color:var(--seal); }
  td.pj select:focus { outline:2px solid var(--indigo); outline-offset:-2px; }
  .icon { border:0; background:transparent; color:#b4bdc9; padding:2px 6px; cursor:pointer;
          border-radius:4px; }
  .icon:hover { color:var(--seal); background:var(--seal-tint); }

  .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
  @media (max-width:760px) {
    body { font-size:15px; }
    .grid2 { grid-template-columns:1fr; }
    .datebar { flex-wrap:wrap; }
    .stamp { font-size:18px; }
    textarea, .focus-row input, .set-row input[type=text] { font-size:16px; }
    section > h2 { flex-wrap:wrap; }
    .set-row { align-items:flex-start; flex-wrap:wrap; }
    .set-row > label:first-child { width:100%; }
  }
  .custom-head { display:flex; align-items:center; gap:8px; }
  .custom-head::before { content:""; width:3px; height:13px; border-radius:2px;
                         background:var(--seal); flex:none; }
  .custom-head input { flex:1; font:inherit; font-size:14px; font-weight:700; letter-spacing:.06em;
                       padding:4px 6px; border:1px solid transparent; background:transparent;
                       border-radius:5px; }
  .custom-head input:hover, .custom-head input:focus { border-color:var(--rule); background:#fff; outline:none; }
  #addCustom { border-style:dashed; color:var(--soft); width:100%; padding:11px; }
  #addCustom:hover { color:var(--indigo); border-color:var(--indigo); background:var(--indigo-tint); }

  /* ── トースト ── */

  /* submit destination guidance */
  button.seal.submit-needed { color:#805a08; border-color:#d9bd7a; background:#fffaf0; }
  button.seal.submit-needed:hover { color:#5d4000; border-color:#c79b3e; background:#fff4d6; }
  .submit-setup-callout {
    margin:0 0 12px; padding:11px 13px; border:1px solid #dfc17a; border-radius:8px;
    color:#684a08; background:#fffaf0; font-size:13.5px; line-height:1.6;
  }
  .submit-setup-callout.flash { animation:setupPulse 1.1s ease-out; }
  @keyframes setupPulse {
    0% { box-shadow:0 0 0 0 rgba(190,137,34,.4); }
    100% { box-shadow:0 0 0 12px rgba(190,137,34,0); }
  }

  /* help modal */
  body.modal-open { overflow:hidden; }
  .modal-layer { position:fixed; inset:0; z-index:60; display:none; align-items:center;
                 justify-content:center; padding:20px; background:rgba(22,40,60,.46); }
  .modal-card { width:min(760px,100%); max-height:88vh; overflow:auto; background:#fff;
                border-radius:12px; border:1px solid var(--rule); box-shadow:0 20px 60px rgba(22,40,60,.28); }
  .modal-head { position:sticky; top:0; z-index:1; display:flex; align-items:center; gap:10px;
                padding:14px 17px; background:#fff; border-bottom:1px solid var(--rule); }
  .modal-head h2 { margin:0; font-size:19px; }
  .modal-body { padding:16px 18px 20px; }
  .modal-body h3 { margin:20px 0 7px; font-size:16px; color:var(--indigo); }
  .modal-body h3:first-child { margin-top:0; }
  .modal-body p, .modal-body li { font-size:14.5px; line-height:1.75; }
  .modal-body ol, .modal-body ul { padding-left:1.5em; }
  .modal-note { padding:10px 12px; border-radius:8px; background:var(--indigo-tint);
                color:#29466d; font-size:13.5px; }
  #toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%) translateY(8px);
           background:var(--ink); color:#fff; padding:9px 18px; border-radius:22px;
           opacity:0; transition:opacity .25s, transform .25s; pointer-events:none;
           font-size:14px; z-index:10; box-shadow:0 6px 20px rgba(22,40,60,.3); }
  #toast.show { opacity:1; transform:translateX(-50%) translateY(0); }

  /* ── 提出印 ── */
  #seal { position:fixed; inset:0; display:none; place-items:center; pointer-events:none; z-index:30; }
  #seal.on { display:grid; }
  #seal .mark { width:132px; height:132px; border-radius:50%; border:5px double var(--seal);
                color:var(--seal); font-family:"Hiragino Mincho ProN", "Yu Mincho", serif; font-weight:900;
                font-size:30px; display:grid; place-items:center; background:rgba(255,255,255,.92);
                writing-mode:vertical-rl; letter-spacing:.2em; padding-top:.2em;
                animation:stamp 1.3s cubic-bezier(.2,1.3,.35,1) forwards; }
  @keyframes stamp {
    0%   { transform:scale(2.3) rotate(-20deg); opacity:0; }
    22%  { transform:scale(1) rotate(-9deg); opacity:1; }
    34%  { transform:scale(1.05) rotate(-9deg); }
    44%  { transform:scale(1) rotate(-9deg); }
    82%  { opacity:1; }
    100% { transform:scale(1) rotate(-9deg); opacity:0; }
  }
  @media (prefers-reduced-motion: reduce) {
    * { animation-duration:.01ms !important; transition-duration:.01ms !important; }
    .stamp { transform:none; }
  }
</style>
</head>
<body>

<!-- ── 起動中 ── -->
<div id="boot">
  <div class="spin"></div>
  <p>読み込んでいます…</p>
  <div class="google-standard-note" role="note">
    画面上部のGoogle Apps Scriptに関する表示は、Googleが付ける標準の案内です。
  </div>
</div>

<!-- ── 認証ゲート ── -->
<div id="gate" style="display:none">
  <h2 id="gateTitle">PINを入力してください</h2>
  <p id="gateMsg">配布された個人用URLに設定されたPINです。</p>
  <input type="password" id="pinInput" inputmode="numeric" autocomplete="off"
         aria-describedby="gateErr pinBusy"
         onkeydown="if(event.key==='Enter')tryPin()">
  <div id="gateErr" role="alert" aria-live="polite"></div>
  <p id="pinActions"><button id="pinButton" class="primary" onclick="tryPin()">開く</button></p>
  <div id="pinBusy" role="status" aria-live="polite">
    <span class="mini-spin" aria-hidden="true"></span>
    <span id="pinBusyText">認証して日報を読み込んでいます…</span>
  </div>
  <p><button type="button" onclick="openHelp()">使い方を見る</button></p>
  <div class="google-standard-note" role="note">
    画面上部の「Google Apps Script のユーザーによって作成されたものです」や
    「不正行為を報告」は、Googleが表示する標準の案内です。本アプリのエラーや不正判定ではありません。
  </div>
</div>

<!-- ── 本体 ── -->
<div class="wrap" id="app" style="display:none">

  <header>
    <div class="bar">
      <h1>業務日報</h1>
      <span class="who" id="who"></span>
      <span class="spacer"></span>
      <span id="status"></span>
      <button class="primary" onclick="save()">保存</button>
      <button onclick="pdf()">PDF出力</button>
      <button onclick="openHelp()" title="操作方法を表示">使い方</button>
      <button class="seal" onclick="submit()" id="btnSubmit" title="設定した提出先フォルダにPDFを保存">提出</button>
      <button onclick="togglePanel()" title="設定">⚙</button>
    </div>
    <div class="datebar">
      <button class="nav" onclick="go(-1)" title="前日">←</button>
      <span class="stamp" id="dateLabel"></span>
      <button class="nav" onclick="go(1)" title="翌日">→</button>
      <button class="today" onclick="jump()">今日へ</button>
      <input type="date" id="datePicker" onchange="pick(this.value)">
      <span class="spacer"></span>
    </div>
  </header>

  <details class="google-standard-note app-google-note">
    <summary>画面上部のGoogle表示について</summary>
    <div>「Google Apps Script のユーザーによって作成されたものです」や「不正行為を報告」は、Googleが表示する標準の案内です。本アプリのエラーや不正判定ではありません。</div>
  </details>

  <section id="panel" style="display:none">
    <h2>設定</h2>
    <div class="body">
      <div id="submitSetupCallout" class="submit-setup-callout" style="display:none" role="status">
        提出機能を使うには、Google Driveの保存先フォルダを設定してください。
        下の欄へフォルダURLまたはフォルダIDを貼り付けて「設定」を押します。
      </div>
      <div class="set-row">
        <label>提出先フォルダ</label>
        <input type="text" id="folderInput" placeholder="DriveのフォルダURL、またはフォルダIDを貼り付け">
        <button onclick="saveFolder()">設定</button>
        <button onclick="dropFolder()">解除</button>
      </div>
      <div class="set-row">
        <label></label><span id="folderState" class="note"></span>
      </div>
      <div class="set-row">
        <label></label>
        <span><input type="checkbox" id="autoSubmit" onchange="onAuto()">
        <label for="autoSubmit" style="width:auto;cursor:pointer">保存と同時に自動で提出する</label></span>
      </div>
      <p class="note" style="margin:6px 0 0">
        同じ日のPDFが既にあれば差し替えます(旧版はゴミ箱へ)。ファイル名は <code>日報_2026-07-17_氏名.pdf</code> です。
      </p>
    </div>
  </section>

  <section>
    <h2>今日の重点 <span class="note">3つまで/業務でも気持ちでも</span></h2>
    <div class="body" id="focus"></div>
  </section>

  <section>
    <h2>スケジュール <span class="note">予定と実績</span>
      <span class="spacer"></span>
      <button onclick="addRow()" style="padding:3px 9px;font-size:13.5px">+行を追加</button>
    </h2>
    <div class="body schedule-scroll" style="padding:0">
      <table>
        <thead>
          <tr><th colspan="3" class="plan">予定</th><th colspan="4" class="act">実績</th><th></th></tr>
          <tr><th>開始</th><th>終了</th><th>業務内容</th><th>開始</th><th>終了</th><th>PJ</th><th>業務内容</th><th></th></tr>
        </thead>
        <tbody id="sched"></tbody>
      </table>
    </div>
  </section>

  <div class="grid2">
    <section><h2>予定外の業務</h2><div class="body"><textarea id="unplanned" oninput="markDirty()"></textarea></div></section>
    <section><h2>気づき</h2><div class="body"><textarea id="insights" oninput="markDirty()"></textarea></div></section>
  </div>

  <div class="grid2">
    <section>
      <h2>明日の重点
        <span class="spacer"></span>
        <span class="note">転記先</span>
        <input type="date" id="nextDate" onchange="onNextDate()" title="この重点を転記する日">
        <span class="note" id="nextDow"></span>
        <select id="nextRule" onchange="onRule()" title="転記先の既定値の決め方">
          <option value="business">既定:翌営業日</option>
          <option value="next">既定:翌日</option>
        </select>
      </h2>
      <div class="body" id="tmrFocus"></div>
    </section>
    <section><h2>明日の予定 <span class="note">主なもの</span></h2>
      <div class="body"><textarea id="tmrPlan" style="min-height:96px" oninput="markDirty()"></textarea></div></section>
  </div>

  <div class="grid2">
    <section><h2>持ち越し <span class="note">継続的な課題など</span></h2>
      <div class="body"><textarea id="carryover" oninput="markDirty()"></textarea></div></section>
    <section><h2>メモ</h2><div class="body"><textarea id="memo" oninput="markDirty()"></textarea></div></section>
  </div>

  <div id="customArea"></div>
  <button id="addCustom" onclick="addCustom()">+ 独自の項目を追加</button>

</div>

<div id="helpModal" class="modal-layer" aria-hidden="true"
     onclick="if(event.target===this)closeHelp()">
  <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="helpTitle">
    <div class="modal-head">
      <h2 id="helpTitle">業務日報の使い方</h2>
      <span class="spacer"></span>
      <button id="helpClose" type="button" onclick="closeHelp()">閉じる</button>
    </div>
    <div class="modal-body">
      <h3>1. 開く</h3>
      <p>管理者から配布された個人用URLを開きます。PINが設定されている場合はPINを入力し、「開く」を押してください。</p>

      <h3>2. 入力する</h3>
      <ol>
        <li>「今日の重点」に、その日の優先事項を3つまで入力します。</li>
        <li>スケジュールの左側へ予定、右側へ実績の時刻・PJ・業務内容を入力します。</li>
        <li>必要に応じて、予定外の業務、気づき、明日の重点、持ち越し、メモを入力します。</li>
      </ol>

      <h3>3. 保存する</h3>
      <p>入力後は画面上部の「保存」を押します。日付を移動する前や画面を閉じる前に、「保存済」の表示を確認してください。</p>

      <h3>4. PDF出力・提出</h3>
      <p>「PDF出力」は端末へPDFを保存します。「提出」は設定済みのGoogle DriveフォルダへPDFを保存します。</p>
      <div class="modal-note">提出先が未設定の場合、上部のボタンは「提出先を設定」と表示されます。押すと設定欄が開くので、保存先フォルダのURLまたはIDを登録してください。</div>

      <h3>5. 画面上部のGoogle表示</h3>
      <p>「Google Apps Script のユーザーによって作成されたものです」や「不正行為を報告」はGoogleの標準表示で、本アプリのエラーや不正判定ではありません。</p>
    </div>
  </div>
</div>

<div id="toast"></div>
<div id="seal"><div class="mark">提出済</div></div>

<script>
var TOKEN = '<?= token ?>';
var PIN = '';
var SESSION = null;
var SETTINGS = { folderId: '', autoSubmit: false, nextRule: 'business' };
var state = null;
var dirty = false;
var SETTINGS_META_LOADING = false;
var bootTimer = null;
var pinSubmitting = false;

/* ---------- 起動 / 認証 ---------- */
function hideBoot() { document.getElementById('boot').style.display = 'none'; }
function setPinBusy(on, message) {
  pinSubmitting = !!on;
  var gate = document.getElementById('gate');
  var input = document.getElementById('pinInput');
  var button = document.getElementById('pinButton');
  var busy = document.getElementById('pinBusy');
  var busyText = document.getElementById('pinBusyText');
  if (input) input.disabled = pinSubmitting;
  if (button) {
    button.disabled = pinSubmitting;
    button.textContent = pinSubmitting ? '\u78ba\u8a8d\u4e2d\u2026' : '\u958b\u304f';
  }
  if (busy) busy.style.display = pinSubmitting ? 'flex' : 'none';
  if (busyText && message) busyText.textContent = message;
  if (gate) {
    if (pinSubmitting) gate.setAttribute('aria-busy', 'true');
    else gate.removeAttribute('aria-busy');
  }
}
function startBootTimer() {
  clearTimeout(bootTimer);
  var bootMessage = document.querySelector('#boot p');
  var pinMessage = document.getElementById('pinBusyText');
  if (bootMessage) bootMessage.textContent = '\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u2026';
  if (pinSubmitting && pinMessage) {
    pinMessage.textContent = '\u8a8d\u8a3c\u3057\u3066\u65e5\u5831\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u2026';
  }
  bootTimer = setTimeout(function () {
    if (pinSubmitting) {
      var slowPin = document.getElementById('pinBusyText');
      if (slowPin) slowPin.textContent = '\u521d\u56de\u8d77\u52d5\u306b\u6642\u9593\u304c\u304b\u304b\u3063\u3066\u3044\u307e\u3059\u3002\u8aad\u307f\u8fbc\u307f\u3092\u7d9a\u3051\u3066\u3044\u307e\u3059\u2026';
    } else {
      var slowBoot = document.querySelector('#boot p');
      if (slowBoot) slowBoot.textContent = '\u521d\u56de\u8d77\u52d5\u306b\u6642\u9593\u304c\u304b\u304b\u3063\u3066\u3044\u307e\u3059\u3002\u8aad\u307f\u8fbc\u307f\u3092\u7d9a\u3051\u3066\u3044\u307e\u3059\u2026';
    }
  }, 12000);
}

/** 初回表示は認証・設定・PJ・日報をgetInitialDataの1回で取得する。 */
function boot() {
  window.addEventListener('error', function (e) {
    var message = String((e && e.message) || e);
    if (SESSION) toast('画面エラー: ' + message);
    else fatal('エラーが発生しました', message + '\n\n再読み込みしても直らない場合は、この文言を管理者にお伝えください。');
  });
  if (!TOKEN) { fatal('URLが正しくありません', '個人用のURLから開いてください。ご不明な場合は管理者に連絡してください。'); return; }
  openSession('');
}
function openSession(pin) {
  startBootTimer();
  google.script.run
    .withSuccessHandler(function (payload) {
      clearTimeout(bootTimer);
      setPinBusy(false);
      var s = payload.session;
      SESSION = s; PIN = pin;
      SETTINGS = s.settings;
      PJS = payload.pjs || [];
      document.getElementById('who').textContent = s.name + (s.email ? '(' + s.email + ')' : '');
      applySettings(s.settings);
      render(payload.report);
      hideBoot();
      document.getElementById('gate').style.display = 'none';
      document.getElementById('app').style.display = '';
    })
    .withFailureHandler(function (e) {
      clearTimeout(bootTimer);
      setPinBusy(false);
      hideBoot();
      var m = String(e.message || e);
      if (m.indexOf('PIN_REQUIRED') >= 0) { showGate(''); }
      else if (m.indexOf('PIN_WRONG') >= 0) { showGate('PINが違います'); }
      else if (m.indexOf('DISABLED') >= 0) { fatal('このURLは無効です', '管理者に連絡してください。'); }
      else if (m.indexOf('BAD_TOKEN') >= 0 || m.indexOf('NO_TOKEN') >= 0) { fatal('URLが正しくありません', '個人用のURLから開いてください。'); }
      else { fatal('エラー', m); }
    })
    .getInitialData(TOKEN, pin, '');
}
function showGate(errMsg) {
  clearTimeout(bootTimer);
  setPinBusy(false);
  hideBoot();
  document.getElementById('app').style.display = 'none';
  document.getElementById('gate').style.display = '';
  document.getElementById('gateTitle').textContent = 'PIN\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044';
  document.getElementById('gateMsg').textContent = '\u914d\u5e03\u3055\u308c\u305f\u500b\u4eba\u7528URL\u306b\u8a2d\u5b9a\u3055\u308c\u305fPIN\u3067\u3059\u3002';
  document.getElementById('gateErr').textContent = errMsg || '';
  var i = document.getElementById('pinInput');
  i.style.display = '';
  document.getElementById('pinActions').style.display = '';
  i.value = '';
  i.disabled = false;
  i.focus();
}
function tryPin() {
  if (pinSubmitting) return;
  var input = document.getElementById('pinInput');
  var pin = input.value.trim();
  document.getElementById('gateErr').textContent = '';
  setPinBusy(true, '\u8a8d\u8a3c\u3057\u3066\u65e5\u5831\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u2026');
  openSession(pin);
}
function fatal(title, msg) {
  clearTimeout(bootTimer);
  setPinBusy(false);
  hideBoot();
  document.getElementById('app').style.display = 'none';
  document.getElementById('gate').style.display = '';
  document.getElementById('gateTitle').textContent = title;
  var gm = document.getElementById('gateMsg');
  gm.textContent = msg;
  gm.style.whiteSpace = 'pre-wrap';
  document.getElementById('pinInput').style.display = 'none';
  document.getElementById('pinActions').style.display = 'none';
}

var helpLastFocus = null;
function openHelp() {
  var modal = document.getElementById('helpModal');
  helpLastFocus = document.activeElement;
  modal.style.display = 'flex';
  modal.setAttribute('aria-hidden', 'false');
  document.body.classList.add('modal-open');
  setTimeout(function () { document.getElementById('helpClose').focus(); }, 0);
}
function closeHelp() {
  var modal = document.getElementById('helpModal');
  if (modal.style.display === 'none' || !modal.style.display) return;
  modal.style.display = 'none';
  modal.setAttribute('aria-hidden', 'true');
  document.body.classList.remove('modal-open');
  if (helpLastFocus && helpLastFocus.focus) helpLastFocus.focus();
}


/* ---------- 日付ユーティリティ ---------- */
function pad(n) { return ('0' + n).slice(-2); }
function todayStr() {
  var d = new Date();
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
function shift(ds, n) {
  var p = ds.split('-').map(Number);
  var d = new Date(p[0], p[1] - 1, p[2]);
  d.setDate(d.getDate() + n);
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
function label(ds) {
  var p = ds.split('-').map(Number);
  var w = ['日','月','火','水','木','金','土'][new Date(p[0], p[1] - 1, p[2]).getDay()];
  return p[0] + '年' + p[1] + '月' + p[2] + '日(' + w + ')';
}
function toast(msg) {
  var t = document.getElementById('toast');
  t.textContent = msg; t.classList.add('show');
  setTimeout(function () { t.classList.remove('show'); }, 2400);
}
function markDirty() {
  dirty = true;
  var s = document.getElementById('status');
  s.textContent = '● 未保存'; s.className = 'dirty';
}
function clearDirty(at) {
  dirty = false;
  var s = document.getElementById('status');
  s.textContent = at ? '保存済 ' + at : ''; s.className = '';
}
function showSeal() {
  var el = document.getElementById('seal');
  el.classList.remove('on');
  void el.offsetWidth;          // アニメーションを確実に再生させる
  el.classList.add('on');
  setTimeout(function () { el.classList.remove('on'); }, 1400);
}
function err(e) { toast('エラー: ' + (e.message || e)); document.getElementById('status').textContent = ''; }

/* ---------- 読み込み ---------- */
function go(n) { confirmLeave(function () { load(shift(state.date, n)); }); }
function jump() { confirmLeave(function () { load((SESSION && SESSION.today) || todayStr()); }); }
function pick(v) { confirmLeave(function () { load(v); }, function () { document.getElementById('datePicker').value = state.date; }); }
function confirmLeave(fn, cancel) {
  if (dirty && !confirm('未保存の変更があります。破棄して移動しますか?')) { if (cancel) cancel(); return; }
  fn();
}
function load(dateStr) {
  document.getElementById('status').textContent = '読込中…';
  google.script.run.withSuccessHandler(render).withFailureHandler(err).getReport(TOKEN, PIN, dateStr);
}

function render(d) {
  state = d;
  document.getElementById('datePicker').value = d.date;
  document.getElementById('dateLabel').textContent = label(d.date);
  document.getElementById('nextDate').value = d.nextDate || '';
  paintNextDow();
  renderFocus('focus', d.focus);
  renderFocus('tmrFocus', d.tmrFocus);
  renderSched(d.schedule);
  ['unplanned','insights','tmrPlan','carryover','memo'].forEach(function (k) {
    document.getElementById(k).value = d[k] || '';
  });
  renderCustom(d.custom || []);
  clearDirty(d.updatedAt);
}

/* ---------- 重点 ---------- */
function renderFocus(id, arr) {
  arr = (arr || []).slice(0, 3);
  while (arr.length < 3) arr.push('');
  var el = document.getElementById(id);
  el.innerHTML = '';
  arr.forEach(function (v, i) {
    var row = document.createElement('div');
    row.className = 'focus-row';
    row.innerHTML = '<span>' + (i + 1) + '</span>';
    var inp = document.createElement('input');
    inp.type = 'text'; inp.value = v; inp.oninput = markDirty;
    row.appendChild(inp);
    el.appendChild(row);
  });
}
function readFocus(id) {
  return Array.prototype.map.call(document.querySelectorAll('#' + id + ' input'), function (i) { return i.value; });
}

/* ---------- 転記先 ---------- */
function paintNextDow() {
  var v = document.getElementById('nextDate').value;
  var el = document.getElementById('nextDow');
  if (!v) { el.textContent = ''; return; }
  var p = v.split('-').map(Number);
  var w = ['日','月','火','水','木','金','土'][new Date(p[0], p[1] - 1, p[2]).getDay()];
  var gap = Math.round((new Date(v) - new Date(state.date)) / 86400000);
  el.textContent = '(' + w + ')' + (gap === 1 ? '' : gap > 0 ? gap + '日後' : '⚠︎');
}
function onNextDate() { paintNextDow(); markDirty(); }
function onRule() {
  var rule = document.getElementById('nextRule').value;
  google.script.run.withSuccessHandler(function (res) {
    SETTINGS = res.settings;
    applySettings(res.settings);
    document.getElementById('nextDate').value = res.nextDate;
    paintNextDow(); markDirty();
    toast('転記先を ' + res.nextDate + ' に更新しました');
  }).withFailureHandler(err).setNextRuleAndCalc(TOKEN, PIN, rule, state.date);
}

/* ---------- スケジュール ---------- */
var FIELDS = ['ps','pe','pt','as','ae','pj','at'];
var PJS = [];
function renderSched(rows) {
  var tb = document.getElementById('sched');
  tb.innerHTML = '';
  (rows || []).forEach(function (r) { tb.appendChild(schedRow(r)); });
}
function pjSelect(val) {
  var s = document.createElement('select');
  s.dataset.f = 'pj';
  s.className = 'pjsel';
  s.appendChild(new Option('―', ''));
  var found = false;
  PJS.forEach(function (p) {
    var o = new Option(p.code, p.code);
    o.title = p.name;
    s.appendChild(o);
    if (p.code === val) found = true;
  });
  // 無効化されたPJが過去の記録に残っている場合も選択肢として保持する
  if (val && !found) s.appendChild(new Option(val + '(無効)', val));
  s.value = val || '';
  s.onchange = markDirty;
  return s;
}
function schedRow(r) {
  var tr = document.createElement('tr');
  FIELDS.forEach(function (f) {
    var td = document.createElement('td');
    if (f === 'pj') {
      td.className = 'pj';
      td.appendChild(pjSelect(r.pj));
      tr.appendChild(td);
      return;
    }
    var isTime = (f === 'ps' || f === 'pe' || f === 'as' || f === 'ae');
    if (isTime) td.className = 'time';
    var inp = document.createElement('input');
    inp.type = isTime ? 'time' : 'text';
    inp.value = r[f] || '';
    inp.dataset.f = f;
    inp.oninput = markDirty;
    td.appendChild(inp);
    tr.appendChild(td);
  });
  var td = document.createElement('td');
  td.className = 'del';
  td.innerHTML = '<button class="icon" title="この行を削除">✕</button>';
  td.firstChild.onclick = function () { tr.remove(); markDirty(); };
  tr.appendChild(td);
  return tr;
}
function addRow() { document.getElementById('sched').appendChild(schedRow({})); markDirty(); }
function readSched() {
  return Array.prototype.map.call(document.querySelectorAll('#sched tr'), function (tr) {
    var o = {};
    Array.prototype.forEach.call(tr.querySelectorAll('input, select'), function (i) { o[i.dataset.f] = i.value; });
    return o;
  });
}

/* ---------- 独自項目 ---------- */
function renderCustom(list) {
  var area = document.getElementById('customArea');
  area.innerHTML = '';
  list.forEach(function (c) { area.appendChild(customEl(c)); });
}
function customEl(c) {
  var sec = document.createElement('section');
  var h = document.createElement('h2');
  h.className = 'custom-head';
  var name = document.createElement('input');
  name.type = 'text'; name.value = c.name || ''; name.placeholder = '項目名を入力';
  name.className = 'cname'; name.oninput = markDirty;
  var del = document.createElement('button');
  del.className = 'icon'; del.textContent = '✕'; del.title = 'この項目を削除';
  del.onclick = function () {
    if (confirm('「' + (name.value || '無題') + '」を削除しますか?')) { sec.remove(); markDirty(); }
  };
  h.appendChild(name); h.appendChild(del);
  var body = document.createElement('div');
  body.className = 'body';
  var ta = document.createElement('textarea');
  ta.className = 'cvalue'; ta.value = c.value || ''; ta.oninput = markDirty;
  body.appendChild(ta);
  sec.appendChild(h); sec.appendChild(body);
  return sec;
}
function addCustom() {
  var el = customEl({ name: '', value: '' });
  document.getElementById('customArea').appendChild(el);
  el.querySelector('.cname').focus();
  markDirty();
}
function readCustom() {
  return Array.prototype.map.call(document.querySelectorAll('#customArea section'), function (s) {
    return { name: s.querySelector('.cname').value, value: s.querySelector('.cvalue').value };
  }).filter(function (c) { return c.name.trim() || c.value.trim(); });
}

/* ---------- 保存 / PDF / 提出 ---------- */
function collect() {
  return {
    date: state.date,
    focus: readFocus('focus'),
    schedule: readSched(),
    unplanned: document.getElementById('unplanned').value,
    insights: document.getElementById('insights').value,
    tmrFocus: readFocus('tmrFocus'),
    tmrPlan: document.getElementById('tmrPlan').value,
    carryover: document.getElementById('carryover').value,
    memo: document.getElementById('memo').value,
    custom: readCustom(),
    nextDate: document.getElementById('nextDate').value
  };
}
function save() {
  document.getElementById('status').textContent = '保存中…';
  google.script.run
    .withSuccessHandler(function (res) {
      clearDirty(res.updatedAt);
      toast(res.carriedTo ? '保存しました(重点を ' + res.carriedTo + ' に転記)' : '保存しました');
      if (SETTINGS.autoSubmit && SETTINGS.folderId) submit(true);
    })
    .withFailureHandler(err)
    .saveReport(TOKEN, PIN, collect());
}
function pdf() {
  if (dirty && !confirm('未保存の変更はPDFに反映されません。このまま出力しますか?')) return;
  toast('PDFを生成中…');
  google.script.run.withSuccessHandler(function (res) {
    var bin = atob(res.base64);
    var buf = new Uint8Array(bin.length);
    for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
    var url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }));
    var a = document.createElement('a');
    a.href = url; a.download = res.filename; a.click();
    setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
  }).withFailureHandler(err).exportPdf(TOKEN, PIN, state.date);
}
function openSettingsForSubmit() {
  var panel = document.getElementById('panel');
  if (panel.style.display === 'none') togglePanel();
  var callout = document.getElementById('submitSetupCallout');
  callout.style.display = '';
  callout.classList.remove('flash');
  void callout.offsetWidth;
  callout.classList.add('flash');
  setTimeout(function () {
    document.getElementById('folderInput').scrollIntoView({ behavior:'smooth', block:'center' });
    document.getElementById('folderInput').focus();
  }, 60);
}
function submit(silent) {
  if (!SETTINGS.folderId) {
    openSettingsForSubmit();
    toast('\u63d0\u51fa\u5148\u30d5\u30a9\u30eb\u30c0\u3092\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044');
    return;
  }
  if (silent !== true && dirty && !confirm('\u672a\u4fdd\u5b58\u306e\u5909\u66f4\u306fPDF\u306b\u53cd\u6620\u3055\u308c\u307e\u305b\u3093\u3002\u3053\u306e\u307e\u307e\u63d0\u51fa\u3057\u307e\u3059\u304b\uff1f')) return;
  if (silent !== true) toast('\u63d0\u51fa\u4e2d\u2026');
  google.script.run.withSuccessHandler(function (res) {
    showSeal();
    toast('\u63d0\u51fa\u3057\u307e\u3057\u305f \u2192 ' + res.folder + ' / ' + res.name);
  }).withFailureHandler(err).submitPdf(TOKEN, PIN, state.date);
}

/* ---------- 設定パネル ---------- */
function togglePanel() {
  var p = document.getElementById('panel');
  var opening = p.style.display === 'none';
  p.style.display = opening ? '' : 'none';

  // 初回表示ではDrive APIを呼ばず、設定パネルを開いた時だけフォルダ名を確認する。
  if (opening && SETTINGS.folderId && !SETTINGS.folderName && !SETTINGS_META_LOADING) {
    SETTINGS_META_LOADING = true;
    google.script.run.withSuccessHandler(function (s) {
      SETTINGS_META_LOADING = false;
      applySettings(s);
    }).withFailureHandler(function (e) {
      SETTINGS_META_LOADING = false;
      err(e);
    }).getSettings(TOKEN, PIN);
  }
}
function applySettings(s) {
  SETTINGS = s;
  document.getElementById('nextRule').value = s.nextRule;
  document.getElementById('autoSubmit').checked = s.autoSubmit;
  document.getElementById('folderInput').value = s.folderId || '';

  var folderState = document.getElementById('folderState');
  folderState.textContent = '';
  if (s.folderId) {
    folderState.appendChild(document.createTextNode('提出先: '));
    var link = document.createElement('a');
    link.href = s.folderUrl;
    link.target = '_blank';
    link.rel = 'noopener';
    link.textContent = s.folderName || s.folderId;
    folderState.appendChild(link);
  } else {
    folderState.textContent = '未設定(「提出」を使うにはフォルダを設定してください)';
  }
  var btnSubmit = document.getElementById('btnSubmit');
  btnSubmit.disabled = false;
  btnSubmit.textContent = s.folderId ? '\u63d0\u51fa' : '\u63d0\u51fa\u5148\u3092\u8a2d\u5b9a';
  btnSubmit.title = s.folderId
    ? '\u8a2d\u5b9a\u3057\u305f\u63d0\u51fa\u5148\u30d5\u30a9\u30eb\u30c0\u306bPDF\u3092\u4fdd\u5b58'
    : '\u63d0\u51fa\u5148\u30d5\u30a9\u30eb\u30c0\u3092\u8a2d\u5b9a\u3057\u307e\u3059';
  btnSubmit.classList.toggle('submit-needed', !s.folderId);

  var setupCallout = document.getElementById('submitSetupCallout');
  setupCallout.style.display = s.folderId ? 'none' : '';
}
function saveFolder() {
  google.script.run.withSuccessHandler(function (s) {
    applySettings(s); toast('提出先を「' + s.folderName + '」に設定しました');
  }).withFailureHandler(err).setFolder(TOKEN, PIN, document.getElementById('folderInput').value);
}
function dropFolder() {
  google.script.run.withSuccessHandler(function (s) {
    applySettings(s); toast('提出先を解除しました');
  }).withFailureHandler(err).clearFolder(TOKEN, PIN);
}
function onAuto() {
  google.script.run.withSuccessHandler(applySettings).withFailureHandler(err)
    .setAutoSubmit(TOKEN, PIN, document.getElementById('autoSubmit').checked);
}

window.addEventListener('beforeunload', function (e) {
  if (dirty) { e.preventDefault(); e.returnValue = ''; }
});
window.addEventListener('keydown', function (e) {
  if (e.key === 'Escape') closeHelp();
});
boot();
</script>
</body>
</html>

SHA-256: 6a646c722cc18356abe497cb06c654558902d95d3b5c2601487b806ca3572fc5

3. 管理画面

Apps ScriptでHTMLファイルを追加し、名前を必ず admin にしてから貼り付けます。

admin.html 450行 / 21.4 KB ファイルを保存
コード全文を表示
<!DOCTYPE html>
<html lang="ja">
<head>
<base target="_top">
<meta charset="utf-8">
<style>
  :root { --line:#d8dce3; --bg:#f6f7f9; --accent:#2f5fa8; --ink:#1c2430; --warn:#c0392b; }
  * { box-sizing: border-box; }
  body { font-family: system-ui, "Hiragino Sans", "Noto Sans JP", sans-serif;
         margin:0; padding:14px; background:var(--bg); color:var(--ink); font-size:14.5px; }
  .tabs { display:flex; gap:4px; border-bottom:2px solid var(--line); margin-bottom:12px; }
  .tabs button { font:inherit; font-size:14.5px; padding:9px 19px; border:0; background:transparent;
                 cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; color:#6b7684; }
  .tabs button.on { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
  .warn { background:#fff6f5; border:1px solid #f0c8c4; color:#8a3a32; border-radius:6px;
          padding:10px 12px; font-size:13px; margin-bottom:10px; line-height:1.5; }
  .url-config { background:#fff; border:1px solid var(--line); border-radius:7px;
                padding:11px 12px; margin-bottom:12px; }
  .url-config .row { display:flex; gap:7px; align-items:center; }
  .url-config strong { white-space:nowrap; font-size:13.5px; }
  .url-config input { flex:1; min-width:180px; font:inherit; font-size:13.5px; padding:7px 8px;
                      border:1px solid var(--line); border-radius:5px; }
  .url-config .state { margin-top:6px; color:#6b7684; font-size:12px; word-break:break-all; }

  table { width:100%; border-collapse:collapse; background:#fff; }
  th, td { border:1px solid var(--line); padding:6px 7px; text-align:left; vertical-align:middle; }
  th { background:#eef1f5; font-size:12.5px; font-weight:600; position:sticky; top:0; }
  tr.off td { background:#f4f5f6; color:#98a1ad; }
  input[type=text] { width:100%; font:inherit; font-size:13.5px; padding:6px;
                     border:1px solid transparent; border-radius:4px; background:transparent; }
  input[type=text]:hover { border-color:var(--line); background:#fff; }
  input[type=text]:focus { border-color:var(--accent); background:#fff; outline:none; }
  button { font:inherit; font-size:12.5px; padding:5px 9px; border:1px solid var(--line);
           background:#fff; border-radius:5px; cursor:pointer; white-space:nowrap; }
  button:hover { background:#eef2f7; }
  button.primary { background:var(--accent); color:#fff; border-color:var(--accent); }
  button.danger { color:var(--warn); }
  .url { font-family: ui-monospace, monospace; font-size:11.5px; color:#6b7684;
         max-width:230px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .foot { margin-top:10px; display:flex; gap:8px; align-items:center; }
  .note { color:#6b7684; font-size:12.5px; }

  .table-scroll { overflow:auto; max-height:490px; border:1px solid var(--line); background:#fff; }
  .table-scroll table { border:0; }
  .table-scroll th:first-child, .table-scroll td:first-child { border-left:0; }
  .table-scroll th:last-child, .table-scroll td:last-child { border-right:0; }
  .summary-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 12px;
                     margin-bottom:10px; background:#fff; border:1px solid var(--line); border-radius:7px; }
  .summary-toolbar input { font:inherit; font-size:13.5px; padding:6px 8px; border:1px solid var(--line); border-radius:5px; }
  .summary-cards { display:grid; grid-template-columns:repeat(5,minmax(120px,1fr)); gap:8px; margin-bottom:10px; }
  .summary-card { background:#fff; border:1px solid var(--line); border-radius:7px; padding:9px 10px; }
  .summary-card .label { color:#6b7684; font-size:11.5px; }
  .summary-card .value { margin-top:2px; font-weight:700; font-size:19px; color:var(--accent); }
  .summary-card.warn-card .value { color:var(--warn); }
  .summary-table td.num { text-align:right; font-variant-numeric:tabular-nums; }
  .summary-table td.missing { color:var(--warn); font-weight:700; }
  .summary-table td.pj-summary { min-width:230px; line-height:1.5; }
  .summary-state { margin-left:auto; color:#6b7684; font-size:12.5px; }
  .help-pane { background:#fff; border:1px solid var(--line); border-radius:7px; padding:15px 17px; line-height:1.75; }
  .help-pane h2 { margin:0 0 10px; font-size:18px; }
  .help-pane h3 { margin:20px 0 7px; color:var(--accent); font-size:15.5px; }
  .help-pane ol, .help-pane ul { padding-left:1.5em; }
  .help-pane code { background:#eef1f5; padding:1px 5px; border-radius:4px; }
  .help-callout { padding:10px 12px; border:1px solid #c8d7ec; background:#f2f6fc; border-radius:6px; }
  @media (max-width:900px) { .summary-cards { grid-template-columns:repeat(2,minmax(120px,1fr)); } }
  #toast { position:fixed; bottom:14px; left:50%; transform:translateX(-50%); background:#1c2430;
           color:#fff; padding:7px 16px; border-radius:20px; opacity:0; transition:opacity .3s;
           pointer-events:none; font-size:13.5px; z-index:20; }
  #toast.show { opacity:1; }
  .w60 { width:60px; } .w110 { width:110px; } .w130 { width:130px; }
</style>
</head>
<body>

<div class="url-config">
  <div class="row">
    <strong>配布URLのベース</strong>
    <input type="text" id="baseUrlInput" placeholder="デプロイ画面の /exec で終わるURL">
    <button class="primary" onclick="saveBaseUrl()">保存・URL再生成</button>
    <button onclick="useAutoBaseUrl()">自動取得</button>
  </div>
  <div class="state" id="baseUrlState">読み込み中…</div>
  <div class="note">Workspaceの <code>/a/会社ドメイン/</code> 付きURLを貼っても、自動で汎用URLへ変換します。</div>
</div>

<div class="tabs">
  <button id="tabUser" class="on" onclick="tab('user')">ユーザー</button>
  <button id="tabPj" onclick="tab('pj')">PJ</button>
  <button id="tabSummary" onclick="tab('summary')">月次サマリー</button>
  <button id="tabHelp" onclick="tab('help')">使い方</button>
</div>

<!-- ── ユーザー ── -->
<div id="paneUser">
  <div class="warn" id="urlWarn" style="display:none">
    まだデプロイされていません。URLはデプロイ後に生成されます。[デプロイ]→[新しいデプロイ]→ ウェブアプリ(実行者:「自分」/アクセス:「全員」)。
  </div>
  <table>
    <thead>
      <tr>
        <th class="w60">ID</th><th class="w110">氏名</th><th>メール<span class="note">(任意・PDFに印字)</span></th>
        <th class="w60">PIN</th><th class="w60">有効</th><th>個人用URL</th>
        <th class="w130">最終アクセス</th><th class="w130"></th>
      </tr>
    </thead>
    <tbody id="userBody"></tbody>
  </table>
  <div class="foot">
    <button class="primary" onclick="addUserRow()">+ ユーザーを追加</button>
    <span class="note">氏名を入れて「保存」するとURLが発行されます。PINは空欄可(外部の方には設定を推奨)。</span>
  </div>
</div>

<!-- ── PJ ── -->
<div id="panePj" style="display:none">
  <div class="warn">
    PJコードは日報の実績が参照するキーです。<b>登録後は変更できません</b>(過去の記録との対応が切れるため)。
    使わなくなったPJは「有効」を外してください。入力欄から消えますが、過去の記録には残ります。
  </div>
  <table>
    <thead>
      <tr><th class="w110">PJコード</th><th>PJ名</th><th class="w60">有効</th><th>備考</th><th class="w110"></th></tr>
    </thead>
    <tbody id="pjBody"></tbody>
  </table>
  <div class="foot">
    <button class="primary" onclick="addPjRow()">+ PJを追加</button>
    <span class="note">日報の実績欄にはPJコードがプルダウンで表示されます(PJ名はマウスオーバーで表示)。</span>
  </div>
</div>


<!-- monthly summary -->
<div id="paneSummary" style="display:none">
  <div class="summary-toolbar">
    <strong>対象月</strong>
    <input type="month" id="summaryMonth">
    <button id="summaryButton" class="primary" onclick="loadSummary()">集計する</button>
    <span id="summaryState" class="summary-state">月を選んで集計します。</span>
  </div>
  <div class="summary-cards">
    <div class="summary-card"><div class="label">有効ユーザー</div><div class="value" id="sumUsers">-</div></div>
    <div class="summary-card"><div class="label">保存日数合計</div><div class="value" id="sumSaved">-</div></div>
    <div class="summary-card"><div class="label">提出済み日数</div><div class="value" id="sumSubmitted">-</div></div>
    <div class="summary-card warn-card"><div class="label">未提出日数</div><div class="value" id="sumMissing">-</div></div>
    <div class="summary-card"><div class="label">実績時間合計</div><div class="value" id="sumHours">-</div></div>
  </div>
  <div class="table-scroll">
    <table class="summary-table">
      <thead><tr>
        <th>ID</th><th>氏名</th><th>状態</th><th>保存日数</th><th>提出済</th>
        <th>未提出</th><th>実績入力日</th><th>実績時間</th><th>時刻未完了</th><th>主なPJ</th>
      </tr></thead>
      <tbody id="summaryBody"><tr><td colspan="10" class="note">まだ集計していません。</td></tr></tbody>
    </table>
  </div>
  <p class="note">保存・提出日数はログの日付を重複除外して集計します。実績時間は「実績開始」と「実績終了」が揃った行のみから算出します。勤怠・給与計算の法定記録ではありません。</p>
</div>

<!-- admin help -->
<div id="paneHelp" style="display:none">
  <div class="help-pane">
    <h2>管理者向け・導入と操作の手順</h2>
    <div class="help-callout">この「使い方」タブは、設置後の管理者がいつでも参照できます。初回設置前は、配布ZIP内の <code>START_HERE.html</code> を開いてください。</div>

    <h3>1. 初回導入</h3>
    <ol>
      <li>Googleスプレッドシートを新規作成します。</li>
      <li>スプレッドシートの「拡張機能」→「Apps Script」を開きます。</li>
      <li><code>Code.gs</code>、<code>index.html</code>、<code>admin.html</code>の3ファイルを登録します。</li>
      <li>Apps Scriptのプロジェクト設定で、タイムゾーンを運用地域に合わせます。</li>
      <li>スプレッドシートを再読み込みし、「業務日報」→「初期セットアップを実行」を選びます。</li>
      <li>「デプロイ」→「新しいデプロイ」→「ウェブアプリ」で公開します。実行者は「自分」、アクセスは「全員」です。</li>
      <li>この管理画面上部の「配布URLのベース」へ <code>/exec</code> で終わるURLを貼り付け、保存します。</li>
    </ol>

    <h3>2. 利用者とPJを登録する</h3>
    <ul>
      <li>「ユーザー」タブで氏名・メール・PINを登録し、個人用URLを本人に配布します。</li>
      <li>「PJ」タブでPJコードと名称を登録します。登録後のPJコードは変更しません。</li>
    </ul>

    <h3>3. 月次の確認</h3>
    <p>「月次サマリー」タブで、ユーザーごとの保存日数、提出済み日数、実績時間、PJ別時間を確認できます。集計はこのタブを開いたときだけ実行されるため、利用者画面の起動速度には影響しません。</p>

    <h3>4. 運用上の注意</h3>
    <ul>
      <li>スプレッドシート本体は利用者へ共有しません。</li>
      <li>個人用URLとPINは別の経路で伝えると安全性が上がります。</li>
      <li>コードを更新した場合は、必ず新しいバージョンとしてウェブアプリを再デプロイします。</li>
    </ul>
  </div>
</div>

<div id="toast"></div>

<script>
var NONCE = '<?= nonce ?>';
var DATA = { users: [], pjs: [], webAppUrl: '', currentMonth: '' };
var SUMMARY_LOADED = false;

function toast(m) {
  var t = document.getElementById('toast');
  t.textContent = m; t.classList.add('show');
  setTimeout(function () { t.classList.remove('show'); }, 2400);
}
function err(e) { toast('エラー: ' + (e.message || e)); }
function tab(n) {
  var names = ['user', 'pj', 'summary', 'help'];
  names.forEach(function (name) {
    var cap = name.charAt(0).toUpperCase() + name.slice(1);
    document.getElementById('pane' + cap).style.display = name === n ? '' : 'none';
    document.getElementById('tab' + cap).className = name === n ? 'on' : '';
  });
  if (n === 'summary' && !SUMMARY_LOADED) loadSummary();
}
function td(el) { var t = document.createElement('td'); if (el) t.appendChild(el); return t; }
function txt(v, ph) {
  var i = document.createElement('input');
  i.type = 'text'; i.value = v || ''; if (ph) i.placeholder = ph;
  return i;
}
function chk(v) {
  var i = document.createElement('input');
  i.type = 'checkbox'; i.checked = v !== false;
  return i;
}
function btn(label, fn, cls) {
  var b = document.createElement('button');
  b.textContent = label; b.onclick = fn; if (cls) b.className = cls;
  return b;
}

/* ---------- 読み込み ---------- */
function renderBaseUrl() {
  var input = document.getElementById('baseUrlInput');
  var state = document.getElementById('baseUrlState');
  input.value = DATA.webAppUrl || '';
  state.textContent = DATA.webAppUrl
    ? '現在の配布ベース: ' + DATA.webAppUrl
    : '配布URLが未登録です。/exec で終わるデプロイURLを保存してください。';
  document.getElementById('urlWarn').style.display = DATA.webAppUrl ? 'none' : '';
}
function reload() {
  google.script.run.withSuccessHandler(function (d) {
    DATA = d;
    renderBaseUrl();
    var monthInput = document.getElementById('summaryMonth');
    if (!monthInput.value) monthInput.value = d.currentMonth || '';
    renderUsers(); renderPjs();
  }).withFailureHandler(err).adminGetAll(NONCE);
}
function saveBaseUrl() {
  var value = document.getElementById('baseUrlInput').value.trim();
  google.script.run.withSuccessHandler(function (d) {
    DATA = d;
    renderBaseUrl(); renderUsers(); renderPjs();
    toast(d.normalized
      ? 'ドメイン限定部分を除去し、全ユーザーのURLを再生成しました'
      : '全ユーザーのURLを再生成しました');
  }).withFailureHandler(err).adminSetBaseUrl(NONCE, value);
}
function useAutoBaseUrl() {
  google.script.run.withSuccessHandler(function (d) {
    DATA = d;
    renderBaseUrl(); renderUsers(); renderPjs();
    toast('自動取得した汎用URLで再生成しました');
  }).withFailureHandler(err).adminSetBaseUrl(NONCE, '');
}

/* ---------- ユーザー ---------- */
function renderUsers() {
  var tb = document.getElementById('userBody');
  tb.innerHTML = '';
  DATA.users.forEach(function (u) { tb.appendChild(userRow(u)); });
}
function userRow(u) {
  var tr = document.createElement('tr');
  if (!u.active) tr.className = 'off';
  var id = document.createElement('td'); id.textContent = u.userId || '(新規)';
  var name = txt(u.name, '氏名');
  var mail = txt(u.email, '(任意)');
  var pin = txt(u.pin, '(なし)');
  var act = chk(u.active);
  act.onchange = function () { tr.className = act.checked ? '' : 'off'; };

  var urlTd = document.createElement('td');
  urlTd.className = 'url';
  urlTd.textContent = u.url || '―';
  urlTd.title = u.url || '';

  var acc = document.createElement('td');
  acc.textContent = u.lastAccess || '―';
  acc.className = 'note';

  var ops = document.createElement('td');
  ops.appendChild(btn('保存', function () {
    google.script.run.withSuccessHandler(function (d) {
      DATA = d; renderUsers(); toast('保存しました');
    }).withFailureHandler(err).adminSaveUser(NONCE, {
      userId: u.userId || '', name: name.value.trim(), email: mail.value.trim(),
      pin: pin.value.trim(), active: act.checked
    });
  }, 'primary'));
  if (u.url) {
    ops.appendChild(btn('URLコピー', function () { copy(u.url + (u.pin ? '' : '')); }));
    ops.appendChild(btn('開く', function () { window.open(u.url, '_blank'); }));
  }
  if (u.userId) {
    ops.appendChild(btn('再発行', function () {
      if (!confirm(u.name + ' さんのトークンを再発行します。\n古いURLは即座に使えなくなります。よろしいですか?')) return;
      google.script.run.withSuccessHandler(function (d) {
        DATA = d; renderUsers(); toast('再発行しました。新しいURLを本人に渡してください。');
      }).withFailureHandler(err).adminReissueToken(NONCE, u.userId);
    }, 'danger'));
  }

  [id, td(name), td(mail), td(pin), td(act), urlTd, acc, ops].forEach(function (c) { tr.appendChild(c); });
  return tr;
}
function addUserRow() {
  document.getElementById('userBody').appendChild(
    userRow({ userId: '', name: '', email: '', pin: '', active: true, url: '', lastAccess: '' }));
}

/* ---------- PJ ---------- */
function renderPjs() {
  var tb = document.getElementById('pjBody');
  tb.innerHTML = '';
  DATA.pjs.forEach(function (p) { tb.appendChild(pjRow(p, false)); });
}
function pjRow(p, isNew) {
  var tr = document.createElement('tr');
  if (!p.active) tr.className = 'off';
  var code = txt(p.code, '例: PJ-001');
  if (!isNew) { code.readOnly = true; code.style.color = '#6b7684'; code.title = 'コードは変更できません'; }
  var name = txt(p.name, 'PJ名');
  var note = txt(p.note, '(任意)');
  var act = chk(p.active);
  act.onchange = function () { tr.className = act.checked ? '' : 'off'; };

  var ops = document.createElement('td');
  ops.appendChild(btn('保存', function () {
    google.script.run.withSuccessHandler(function (d) {
      DATA = d; renderPjs(); toast('保存しました');
    }).withFailureHandler(err).adminSavePj(NONCE, {
      code: code.value.trim(), name: name.value.trim(), active: act.checked, note: note.value.trim()
    });
  }, 'primary'));

  [td(code), td(name), td(act), td(note), ops].forEach(function (c) { tr.appendChild(c); });
  return tr;
}
function addPjRow() {
  document.getElementById('pjBody').appendChild(pjRow({ code: '', name: '', active: true, note: '' }, true));
}

/* ---------- クリップボード ---------- */
/* ---------- monthly summary ---------- */
function formatMinutes(minutes) {
  minutes = Number(minutes || 0);
  var h = Math.floor(minutes / 60);
  var m = minutes % 60;
  if (!h) return m + '\u5206';
  return h + '\u6642\u9593' + (m ? (' ' + m + '\u5206') : '');
}
function pjSummary(pjs) {
  pjs = pjs || [];
  if (!pjs.length) return '\u2015';
  var shown = pjs.slice(0, 4).map(function (p) {
    return p.code + ' ' + formatMinutes(p.minutes);
  });
  if (pjs.length > 4) shown.push('\u307b\u304b' + (pjs.length - 4) + '\u4ef6');
  return shown.join(' / ');
}
function summaryCell(value, cls) {
  var td = document.createElement('td');
  td.textContent = value;
  if (cls) td.className = cls;
  return td;
}
function renderSummary(d) {
  SUMMARY_LOADED = true;
  document.getElementById('summaryMonth').value = d.month;
  document.getElementById('summaryState').textContent = '\u96c6\u8a08: ' + d.generatedAt;
  document.getElementById('sumUsers').textContent = d.totals.activeUsers + '\u4eba';
  document.getElementById('sumSaved').textContent = d.totals.savedDays + '\u65e5';
  document.getElementById('sumSubmitted').textContent = d.totals.submittedDays + '\u65e5';
  document.getElementById('sumMissing').textContent = d.totals.unsubmittedDays + '\u65e5';
  document.getElementById('sumHours').textContent = formatMinutes(d.totals.actualMinutes);

  var tb = document.getElementById('summaryBody');
  tb.innerHTML = '';
  if (!d.rows.length) {
    var tr0 = document.createElement('tr');
    var td0 = summaryCell('\u5bfe\u8c61\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002', 'note');
    td0.colSpan = 10;
    tr0.appendChild(td0);
    tb.appendChild(tr0);
    return;
  }
  d.rows.forEach(function (r) {
    var tr = document.createElement('tr');
    if (!r.active) tr.className = 'off';
    tr.appendChild(summaryCell(r.userId));
    tr.appendChild(summaryCell(r.name));
    tr.appendChild(summaryCell(r.active ? '\u6709\u52b9' : '\u7121\u52b9'));
    tr.appendChild(summaryCell(r.savedDays, 'num'));
    tr.appendChild(summaryCell(r.submittedDays, 'num'));
    tr.appendChild(summaryCell(r.unsubmittedDays, r.unsubmittedDays ? 'num missing' : 'num'));
    tr.appendChild(summaryCell(r.actualDays, 'num'));
    tr.appendChild(summaryCell(formatMinutes(r.actualMinutes), 'num'));
    tr.appendChild(summaryCell(r.incompleteRows, r.incompleteRows ? 'num missing' : 'num'));
    tr.appendChild(summaryCell(pjSummary(r.pjs), 'pj-summary'));
    tb.appendChild(tr);
  });
}
function loadSummary() {
  var month = document.getElementById('summaryMonth').value || DATA.currentMonth;
  var button = document.getElementById('summaryButton');
  var state = document.getElementById('summaryState');
  if (!month) { state.textContent = '\u5bfe\u8c61\u6708\u3092\u9078\u3093\u3067\u304f\u3060\u3055\u3044\u3002'; return; }
  button.disabled = true;
  state.textContent = '\u96c6\u8a08\u4e2d\u2026';
  google.script.run.withSuccessHandler(function (d) {
    button.disabled = false;
    renderSummary(d);
  }).withFailureHandler(function (e) {
    button.disabled = false;
    state.textContent = '\u96c6\u8a08\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002';
    err(e);
  }).adminGetMonthlySummary(NONCE, month);
}

function copy(text) {
  var ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed'; ta.style.opacity = '0';
  document.body.appendChild(ta);
  ta.select();
  try { document.execCommand('copy'); toast('URLをコピーしました'); }
  catch (e) { prompt('コピーしてください:', text); }
  document.body.removeChild(ta);
}

reload();
</script>
</body>
</html>

SHA-256: 3bf8aca4e1b534536df4dd00dd86b0e83bdcca7991dc64905a92075fe4c1fb58