/*! * MyLibrary v1.0.0 * (c) 2026 kisuke(https://spwind.net) * Released under the MIT License. */ const CONFIG = { USERS_SHEET: 'ユーザー', RECORDS_SHEET: '申請利用', SETTINGS_SHEET: '設定', TIME_UNIT: 5, ADMIN_EMAILS: [] // 管理者メールを指定する場合: ['admin@example.com'] }; function getCurrentUserInfo() { const email = String(getCurrentUserEmail() || '').trim().toLowerCase(); const users = getUsers(true); const user = users.find(u => String(u.email || '').trim().toLowerCase() === email) || null; return {email: email, registered: !!user, user: user, isAdmin: isAdminEmail_(email)}; } function getAdminEmails_() { const list = CONFIG.ADMIN_EMAILS.map(x => String(x || '').trim().toLowerCase()).filter(Boolean); const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.SETTINGS_SHEET); if (sh && sh.getLastRow() >= 2) { const v = sh.getDataRange().getValues(); v.slice(1).forEach(r => { const k = String(r[0] || '').trim().toLowerCase(); const x = String(r[1] || ''); if (k === '管理者メール' || k === '管理者メールアドレス') { x.split(/[,\n;]/).map(s => s.trim().toLowerCase()).filter(Boolean).forEach(e => list.push(e)); } }); } return [...new Set(list)]; } function isAdminEmail_(email) { const e = String(email || '').trim().toLowerCase(); return !!e && getAdminEmails_().includes(e); } function getUsersForCurrentAccount() { const info=getCurrentUserInfo(); if (!info.email) throw new Error('Googleアカウントにログインしてください。'); return info.isAdmin ? getUsers(true) : (info.registered ? [info.user] : []); } function requireOwnerOrAdmin_(recordId) { const info=getCurrentUserInfo(); if (!info.email) throw new Error('Googleアカウントにログインしてください。'); if (info.isAdmin) return true; const r=getRawRecords().find(x=>String(x.id)===String(recordId)); if (!r) throw new Error('対象データが見つかりません。'); if (String(r.status)==='承認済み') throw new Error('承認済みのデータは管理者のみ修正・取消できます。'); const owner=String(r.userEmail||r.email||'').trim().toLowerCase(); if (owner && owner!==info.email) throw new Error('他のユーザーのデータは操作できません。'); return true; } function doGet() { return HtmlService.createTemplateFromFile('index') .evaluate() .setTitle('割振り変更管理') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); } function setup() { const ss = SpreadsheetApp.getActiveSpreadsheet(); let us = ss.getSheetByName(CONFIG.USERS_SHEET); if (!us) us = ss.insertSheet(CONFIG.USERS_SHEET); us.clear(); us.getRange(1,1,1,5).setValues([['ユーザーID','ユーザー名','所属','メールアドレス','有効']]); us.getRange(2,1,3,5).setValues([ ['001','山田太郎','情報科','','TRUE'], ['002','佐藤花子','商業科','','TRUE'], ['003','鈴木一郎','工業科','','TRUE'] ]); us.setFrozenRows(1); let rs = ss.getSheetByName(CONFIG.RECORDS_SHEET); if (!rs) rs = ss.insertSheet(CONFIG.RECORDS_SHEET); rs.clear(); rs.getRange(1,1,1,15).setValues([[ 'ID','登録日時','登録者','ユーザーID','ユーザー名','区分', '日付','開始時刻','終了時刻','分数','理由','状態', '承認者','承認日時','更新日時' ]]); rs.setFrozenRows(1); let ssheet = ss.getSheetByName(CONFIG.SETTINGS_SHEET); if (!ssheet) ssheet = ss.insertSheet(CONFIG.SETTINGS_SHEET); ssheet.clear(); ssheet.getRange(1,1,4,2).setValues([ ['設定項目','値'], ['時間単位',5], ['システム名','割振り変更管理'], ['管理者メール',''] ]); return 'セットアップ完了'; } function isAdmin() { const email = String(Session.getActiveUser().getEmail() || '').trim().toLowerCase(); return !!email && getAdminEmails_().includes(email); } function getCurrentUserEmail() { return Session.getActiveUser().getEmail() || ''; } function getUsers(includeInactive) { const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.USERS_SHEET); if (!sh || sh.getLastRow() < 2) return []; return sh.getRange(2,1,sh.getLastRow()-1,5).getValues() .filter(r => r[0] && r[1] && (includeInactive || String(r[4]).toUpperCase() !== 'FALSE')) .map(r => ({id:String(r[0]),name:String(r[1]),department:String(r[2]||''),email:String(r[3]||''),active:String(r[4]).toUpperCase() !== 'FALSE'})); } function getBalance(userId) { const user = getUsers(true).find(u=>u.id===String(userId)); if (!user) throw new Error('ユーザーが見つかりません。'); const records = getRawRecords().filter(r=>r.userId===String(userId) && r.status==='承認済み'); let app=0, use=0; records.forEach(r => r.type==='申請' ? app+=r.minutes : use+=r.minutes); const bal=app-use; return {userId:user.id,userName:user.name,balanceMinutes:bal,balanceText:formatMinutes(bal),applicationMinutes:app,usageMinutes:use}; } function getAllBalances() { return getUsers(false).map(u => getBalance(u.id)); } function registerRecord(data) { if (!data) throw new Error('データがありません。'); const userId=String(data.userId||''), type=String(data.type||''); const date=String(data.date||''), start=String(data.start||''), end=String(data.end||''); const reason=String(data.reason||'').trim(); if (!userId) throw new Error('ユーザー名を選択してください。'); if (!['申請','利用'].includes(type)) throw new Error('区分が不正です。'); if (!date || !start || !end) throw new Error('日付・開始・終了を入力してください。'); if (!reason) throw new Error('理由を入力してください。'); const sm=validateTime_(start,'開始時刻'), em=validateTime_(end,'終了時刻'); if (em<=sm) throw new Error('終了時刻は開始時刻より後にしてください。'); if (sm%5!==0 || em%5!==0) throw new Error('分単位で入力してください。'); const minutes=em-sm; if (type==='利用') { const bal=getBalance(userId); if (minutes>bal.balanceMinutes) throw new Error('残り時間を超えています。現在の残り時間:'+bal.balanceText); } const user=getUsers(true).find(u=>u.id===userId); if (!user) throw new Error('ユーザーが存在しません。'); const id=Utilities.getUuid(), now=new Date(); const email=getCurrentUserEmail() || '不明'; const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); sh.appendRow([ id,now,email,user.id,user.name,type, new Date(date+'T00:00:00'), new Date('1970-01-01T'+start+':00'), new Date('1970-01-01T'+end+':00'), minutes,reason,'申請中','','',now ]); const row=sh.getLastRow(); sh.getRange(row,7).setNumberFormat('yyyy/mm/dd'); sh.getRange(row,8,1,2).setNumberFormat('HH:mm'); return {success:true,message:'申請を登録しました。管理者の承認待ちです。',recordId:id}; } function getRecords(userId, includeAll) { const records=getRawRecords(); let r=records; if (userId) r=r.filter(x=>x.userId===String(userId)); if (!includeAll) r=r.filter(x=>x.status!=='取消'); return r.reverse(); } function getRawRecords() { const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); if (!sh || sh.getLastRow()<2) return []; return sh.getRange(2,1,sh.getLastRow()-1,15).getValues().map(r=>({ id:String(r[0]),registeredAt:formatDateTime(r[1]),registeredBy:String(r[2]||''), userId:String(r[3]),userName:String(r[4]),type:String(r[5]), date:formatDateValue(r[6]),start:formatTimeValue(r[7]),end:formatTimeValue(r[8]), minutes:Number(r[9])||0,duration:formatMinutes(r[9]),reason:String(r[10]||''), status:String(r[11]||''),approver:String(r[12]||''),approvedAt:formatDateTime(r[13]), updatedAt:formatDateTime(r[14]) })); } function requireAdmin() { if (!isAdmin()) throw new Error('管理者権限が必要です。'); } function approveRecord(id) { requireAdmin(); const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); const row=findRecordRow(id); if (!row) throw new Error('対象データがありません。'); const current=sh.getRange(row,12).getValue(); if (current!=='申請中') throw new Error('申請中のデータだけ承認できます。'); sh.getRange(row,12,1,3).setValues([['承認済み',getCurrentUserEmail()||'管理者',new Date()]]); return {success:true}; } function rejectRecord(id) { requireAdmin(); const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); const row=findRecordRow(id); if (!row) throw new Error('対象データがありません。'); const current=sh.getRange(row,12).getValue(); if (current!=='申請中') throw new Error('申請中のデータだけ却下できます。'); sh.getRange(row,12,1,3).setValues([['却下',getCurrentUserEmail()||'管理者',new Date()]]); return {success:true}; } function bulkApproveRecords(ids) { requireAdmin(); if (!Array.isArray(ids) || !ids.length) throw new Error('データが選択されていません。'); const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); let count = 0; const now = new Date(); const email = getCurrentUserEmail() || '管理者'; [...new Set(ids.map(String))].forEach(id => { const row = findRecordRow(id); if (row) { const current = sh.getRange(row, 12).getValue(); if (current === '申請中') { sh.getRange(row, 12, 1, 3).setValues([['承認済み', email, now]]); count++; } } }); return {success:true, message: count + '件の申請を一括承認しました。'}; } function bulkRejectRecords(ids) { requireAdmin(); if (!Array.isArray(ids) || !ids.length) throw new Error('データが選択されていません。'); const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); let count = 0; const now = new Date(); const email = getCurrentUserEmail() || '管理者'; [...new Set(ids.map(String))].forEach(id => { const row = findRecordRow(id); if (row) { const current = sh.getRange(row, 12).getValue(); if (current === '申請中') { sh.getRange(row, 12, 1, 3).setValues([['却下', email, now]]); count++; } } }); return {success:true, message: count + '件の申請を一括却下しました。'}; } function cancelRecord(id) { requireOwnerOrAdmin_(id); const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); const row=findRecordRow(id); if (!row) throw new Error('対象データがありません。'); const data=sh.getRange(row,1,1,15).getValues()[0]; const email=getCurrentUserEmail()||''; if (!isAdmin() && String(data[2])!==email) throw new Error('本人または管理者のみ取消できます。'); if (String(data[11])==='取消') throw new Error('すでに取消されています。'); sh.getRange(row,12).setValue('取消'); sh.getRange(row,15).setValue(new Date()); return {success:true}; } function updateRecord(id,data) { requireOwnerOrAdmin_(id,data); const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); const row=findRecordRow(id); if (!row) throw new Error('対象データがありません。'); const old=sh.getRange(row,1,1,15).getValues()[0]; const email=getCurrentUserEmail()||''; if (!isAdmin() && String(old[2])!==email) throw new Error('本人または管理者のみ修正できます。'); if (String(old[11])==='承認済み' && !isAdmin()) throw new Error('承認済みデータの修正は管理者のみ可能です。'); const sm=validateTime_(data.start,'開始時刻'), em=validateTime_(data.end,'終了時刻'); if (em<=sm) throw new Error('終了時刻は開始時刻より後にしてください。'); if (sm%5!==0 || em%5!==0) throw new Error('5分単位で入力してください。'); const minutes=em-sm; if (data.type==='利用') { const bal=getBalance(String(data.userId)); if (String(old[11])==='承認済み' && String(old[5])==='利用') { const available=bal.balanceMinutes + Number(old[9]||0); if (minutes>available) throw new Error('残り時間を超えています。'); } else if (minutes>bal.balanceMinutes) throw new Error('残り時間を超えています。'); } const user=getUsers(true).find(u=>u.id===String(data.userId)); if (!user) throw new Error('ユーザーが存在しません。'); sh.getRange(row,4,1,8).setValues([[ user.id,user.name,data.type, new Date(data.date+'T00:00:00'), new Date('1970-01-01T'+data.start+':00'), new Date('1970-01-01T'+data.end+':00'), minutes,String(data.reason||'').trim() ]]); sh.getRange(row,7).setNumberFormat('yyyy/mm/dd'); sh.getRange(row,8,1,2).setNumberFormat('HH:mm'); sh.getRange(row,15).setValue(new Date()); if (String(old[11])!=='承認済み') sh.getRange(row,12).setValue('申請中'); return {success:true}; } function deleteRecords(ids) { const info = getCurrentUserInfo(); if (!info.email) throw new Error('Googleアカウントにログインしてください。'); if (!Array.isArray(ids) || !ids.length) throw new Error('削除するデータを選択してください。'); const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); if (!sh || sh.getLastRow() < 2) throw new Error('削除対象がありません。'); const raw = getRawRecords(); const rowsToDelete = []; [...new Set(ids.map(String))].forEach(id => { const r = raw.find(x => String(x.id) === id); if (!r) throw new Error('削除対象のデータが見つかりません。'); // 管理者でなければ自分の未承認申請のみ削除可能 if (!info.isAdmin) { if (r.type !== '申請') throw new Error('申請以外のデータは削除できません。'); if (r.status === '承認済み') throw new Error('承認済みの申請は削除できません。'); if (String(r.registeredBy || '').trim().toLowerCase() !== info.email) { throw new Error('本人以外のデータは削除できません。'); } } const row = findRecordRow(id); if (!row) throw new Error('削除対象の行が見つかりません。'); rowsToDelete.push(row); }); rowsToDelete.sort((a,b)=>b-a).forEach(row => sh.deleteRow(row)); return {success:true, deleted:rowsToDelete.length, message:rowsToDelete.length+'件のデータを削除しました。'}; } function validateTime_(t,label) { const s=String(t||'').trim(); if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(s)) { throw new Error(label+'はHH:MM形式(例:09:30)で入力してください。'); } return timeToMinutes(s); } function findRecordRow(id) { const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); if (!sh || sh.getLastRow()<2) return 0; const ids=sh.getRange(2,1,sh.getLastRow()-1,1).getValues().flat().map(String); const idx=ids.indexOf(String(id)); return idx<0 ? 0 : idx+2; } function bulkRegisterApplications(data) { requireAdmin(); if (!data || !Array.isArray(data.userIds) || data.userIds.length === 0) { throw new Error('登録するユーザーを1人以上選択してください。'); } const date = String(data.date || ''); const start = String(data.start || ''); const end = String(data.end || ''); const reason = String(data.reason || '').trim(); if (!date || !start || !end) throw new Error('日付・開始・終了を入力してください。'); if (!reason) throw new Error('理由を入力してください。'); const sm=validateTime_(start,'開始時刻'), em=validateTime_(end,'終了時刻'); if (em<=sm) throw new Error('終了時刻は開始時刻より後にしてください。'); if (sm%5!==0 || em%5!==0) throw new Error('5分単位で入力してください。'); const minutes = em - sm; const allUsers = getUsers(true); const selected = [...new Set(data.userIds.map(String))] .map(id => allUsers.find(u => u.id === id)) .filter(Boolean); if (!selected.length) throw new Error('有効なユーザーが選択されていません。'); const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.RECORDS_SHEET); const now = new Date(); const email = getCurrentUserEmail() || '管理者'; const dateValue = new Date(date + 'T00:00:00'); const startValue = new Date('1970-01-01T' + start + ':00'); const endValue = new Date('1970-01-01T' + end + ':00'); const existing = getRawRecords(); const rows = [], skipped = []; selected.forEach(user => { const duplicate = existing.some(r => r.userId === user.id && r.type === '申請' && r.date === date && r.start === start && r.end === end && r.status !== '取消' ); if (duplicate) { skipped.push(user.name); return; } rows.push([ Utilities.getUuid(), now, email, user.id, user.name, '申請', dateValue, startValue, endValue, minutes, reason, '承認済み', email, now, now ]); }); if (rows.length) { sh.getRange(sh.getLastRow() + 1, 1, rows.length, 15).setValues(rows); const first = sh.getLastRow() - rows.length + 1; sh.getRange(first, 7, rows.length, 1).setNumberFormat('yyyy/mm/dd'); sh.getRange(first, 8, rows.length, 2).setNumberFormat('HH:mm'); } return { success: true, registered: rows.length, skipped: skipped.length, message: rows.length + '名に申請時間を登録しました。' + (skipped.length ? '\n重複のためスキップ:' + skipped.join('、') : '') }; } function getMonthlySummary(year,month) { requireAdmin(); year=Number(year); month=Number(month); const records=getRawRecords().filter(r=>r.status==='承認済み'); const users=getUsers(true); return users.map(u=>{ let app=0,use=0; records.forEach(r=>{ if(r.userId!==u.id) return; const d=r.date.split('-'); if(Number(d[0])===year && Number(d[1])===month) { if(r.type==='申請') app+=r.minutes; else use+=r.minutes; } }); return {userId:u.id,userName:u.name,department:u.department,applicationMinutes:app,usageMinutes:use,balanceMinutes:app-use}; }); } function getAdminDashboard() { requireAdmin(); const records=getRawRecords(); const pending=records.filter(r=>r.status==='申請中'); return { balances:getAllBalances(), pending:pending.reverse(), totals:{ pending:pending.length, approved:records.filter(r=>r.status==='承認済み').length, rejected:records.filter(r=>r.status==='却下').length, canceled:records.filter(r=>r.status==='取消').length } }; } function timeToMinutes(t) { const p=String(t).split(':'); return Number(p[0])*60+Number(p[1]); } function formatMinutes(m) { m=Number(m)||0; const sign=m<0?'-':''; m=Math.abs(m); return sign+Math.floor(m/60)+'時間'+String(m%60).padStart(2,'0')+'分'; } function formatDateValue(v) { if(!v) return ''; if(!(v instanceof Date)) return String(v); return Utilities.formatDate(v,Session.getScriptTimeZone(),'yyyy-MM-dd'); } function formatTimeValue(v) { if(!v) return ''; if(!(v instanceof Date)) return String(v); return Utilities.formatDate(v,Session.getScriptTimeZone(),'HH:mm'); } function formatDateTime(v) { if(!v) return ''; if(!(v instanceof Date)) return String(v); return Utilities.formatDate(v,Session.getScriptTimeZone(),'yyyy/MM/dd HH:mm'); }