SOURCE CODE
Apps Scriptへ登録するコード
次の3ファイルを、それぞれ対応するApps Scriptファイルへ貼り付けます。各「コードをコピー」ボタンは、表示を開かなくても全文をコピーできます。
公開用の初期コードです。実際の個人用URL、トークン、PIN、DriveフォルダID、スプレッドシートIDは含まれていません。運用後のシート内容やプロパティを、このサイトへ転載しないでください。
1. サーバーコード
Apps Scriptのスクリプトファイルへ貼り付けます。表示名は Code.gs または コード.gs で構いません。
コード全文を表示
/**
* 業務日報アプリ ─ サーバーサイド
*
* 認証 : 個人別トークン(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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').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) || ' ') + '</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) || ' ') + '</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) || ' ') + '</div></div></div>' +
'<div class="col"><div class="box"><div class="h">気づき</div><div class="b">' + (esc_(d.insights) || ' ') + '</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) || ' ') + '</div></div></div>' +
'</div>' +
'<div class="cols">' +
'<div class="col"><div class="box"><div class="h">持ち越し</div><div class="b">' + (esc_(d.carryover) || ' ') + '</div></div></div>' +
'<div class="col"><div class="box"><div class="h">メモ</div><div class="b">' + (esc_(d.memo) || ' ') + '</div></div></div>' +
'</div>' +
customHtml +
'</body></html>';
}
SHA-256: ffddad2ce979caff9b2ae539229bdded68819e21e5beb264fad5eb4bd895655f
2. 利用者画面
Apps ScriptでHTMLファイルを追加し、名前を必ず index にしてから貼り付けます。
コード全文を表示
<!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 にしてから貼り付けます。
コード全文を表示
<!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