⬅ 入口 📖 マニュアル
⚠️

ヒヤリ・改善・異常・クレーム

社内専用 — 名前を選んでログイン

🔑 パスワードをお忘れの方は、社長・事務にご連絡ください
v4.1 (2026-06-27)
'; var w=window.open('','_blank'); if(!w){alert('ポップアップを許可してください');return;} w.document.write(html); w.document.close(); } function printOneReport(id){ var r=(state.reports||[]).find(function(x){return String(x.id)===String(id);}); if(!r)return; var data=[['種別',r.type==='hiyari'?'⚠️ ヒヤリハット':'💡 改善提案'],['場所',r.place],['題名',r.title],['危険度',r.severity],['状態',_stJa(r)],['内容',r.body],['効果',r.effect],['発生日時',r.occurredAt?String(r.occurredAt).replace('T',' '):''],['報告者',r.byName],['報告日時',fmtDT_(r.createdAt)],['対応',r.response?r.response+'('+(r.respBy||'')+')':'']]; var body=data.filter(function(x){return x[1];}).map(function(x){return ''+x[0]+''+pEsc(x[1]).replace(/\n/g,'
')+'';}).join(''); var html='安全報告

🛡 安全・カイゼン 報告

'+body+'
'; var w=window.open('','_blank'); if(!w){alert('ポップアップを許可してください');return;} w.document.write(html); w.document.close(); } // ============================================ // v4.0 異常発生・クレーム:ラベル・是正フロー・カレンダー・緊急バナー // ============================================ const Q_STATUS = ["occurred","firstaid","investigating","countering","verifying","closed"]; function isQ_(r){ return r.type === "ijou" || r.type === "claim"; } function procLabel_(c){ return c ? (PROC_KEYS.indexOf(c)>=0 ? t("proc_"+c) : c) : ""; } function shipLabel_(c){ return c ? (t("ship_"+c) || c) : ""; } function ccatLabel_(c){ return c ? (CCAT_KEYS.indexOf(c)>=0 ? t("ccat_"+c) : c) : ""; } function urgLabel_(c){ return c ? (t("urg_"+c) || c) : ""; } function qstLabel_(c){ return c ? (t("qst_"+c) || c) : ""; } function qStepIndex_(r){ var i = Q_STATUS.indexOf(r.status); return i < 0 ? 0 : i; } function corBarHtml_(r){ var idx = qStepIndex_(r), closed = r.status === "closed"; return '
' + Q_STATUS.map(function(s,i){ return '
'; }).join("") + "
"; } function qCardMeta_(r){ var lines = []; if (r.type === "ijou") { lines.push("🏭 " + escapeHtml(procLabel_(r.process)) + (r.target ? "|" + escapeHtml(TGT_KEYS.indexOf(r.target)>=0 ? t("tgt_"+r.target) : r.target) : "")); lines.push("🛑 " + escapeHtml(pickF_(r,"body"))); var imp = []; if (r.defectQty) imp.push("📉" + escapeHtml(r.defectQty)); if (r.stopMin && Number(r.stopMin) > 0) imp.push("⏱" + escapeHtml(r.stopMin) + "分"); if (r.shipImpact && r.shipImpact !== "none") imp.push("🚚" + escapeHtml(shipLabel_(r.shipImpact))); if (imp.length) lines.push(imp.join(" ")); if (r.firstAid) lines.push("🚑 " + escapeHtml(r.firstAid)); } else { lines.push("🏢 " + escapeHtml(r.custName || "") + (r.product ? "|📦" + escapeHtml(r.product) : "")); lines.push("🗣 " + escapeHtml(pickF_(r,"body"))); var c2 = []; if (r.claimCat) c2.push("🗂" + escapeHtml(ccatLabel_(r.claimCat))); if (r.urgency) c2.push(escapeHtml(urgLabel_(r.urgency))); if (c2.length) lines.push(c2.join(" ")); if (r.firstReply) lines.push("💬 " + escapeHtml(r.firstReply)); } if (r.cause) lines.push("🔍 " + escapeHtml(r.cause)); if (r.countermeasure) lines.push("🛠 " + escapeHtml(r.countermeasure)); if (r.linkId) lines.push('🔗 ' + escapeHtml(t("t_kaizen")) + ""); return lines.join("
"); } function qCardHtml_(r){ var d = fmtDT_(r.createdAt); var isClaim = r.type === "claim"; var icon = (r.urgent === "1" ? "🚨" : "") + (isClaim ? "📣" : "🛑"); var head = isClaim ? (icon + " " + escapeHtml(r.custName || t("t_claim"))) : (icon + " " + escapeHtml(procLabel_(r.process) || t("t_ijou"))); var actions = ''; if (canManage_()) actions = '" + actions; if (state.user && (state.user.role === "admin" || state.user.role === "president")) { actions += ''; } var occ = r.occurredAt ? ("
🕐 " + escapeHtml(String(r.occurredAt).replace("T"," "))) : ""; return '
' + '
' + head + '
' + escapeHtml(qstLabel_(r.status)) + "
" + '
' + qCardMeta_(r) + occ + "
" + t("by") + ": " + escapeHtml(r.byName || "") + "|" + d + "
" + corBarHtml_(r) + repPhotosHtml_(r) + '
' + actions + "
" + "
"; } // ── 是正・対応モーダル(原因→恒久対策→効果確認→水平展開) ── var _corId = null; function openCor(id){ var r = state.reports.find(function(x){ return String(x.id) === String(id); }); if (!r) return; _corId = id; var subj = (r.type === "claim" ? ("📣 " + (r.custName || "")) : ("🛑 " + procLabel_(r.process))) + ":" + String(pickF_(r,"body") || "").slice(0,40); document.getElementById("cor-subject").textContent = subj; var set = function(id2,v){ var e = document.getElementById(id2); if (e) e.value = v || ""; }; set("cor-status", r.status || "occurred"); set("cor-cause", r.cause); set("cor-4m", r.causeType); set("cor-cm", r.countermeasure); set("cor-dept", r.cmDept); set("cor-due", r.cmDue); set("cor-effres", r.effectResult); set("cor-eff", r.effectNote); set("cor-yoko", r.yokoNote); document.getElementById("cor-modal").style.display = "flex"; } function closeCor(){ var m = document.getElementById("cor-modal"); if (m) m.style.display = "none"; _corId = null; } async function saveCor(){ var r = state.reports.find(function(x){ return String(x.id) === String(_corId); }); if (!r){ closeCor(); return; } var g = function(id){ var e = document.getElementById(id); return e ? e.value : ""; }; var fields = { status: g("cor-status"), cause: g("cor-cause").trim(), causeType: g("cor-4m"), countermeasure: g("cor-cm").trim(), cmDept: g("cor-dept"), cmDue: g("cor-due"), effectResult: g("cor-effres"), effectNote: g("cor-eff").trim(), yokoNote: g("cor-yoko").trim(), respBy: (state.user && state.user.name) || "", respAt: new Date().toISOString() }; Object.assign(r, fields); saveState(); renderAll(); var res = await gasMutate_({ action: "updateReport", reportId: _corId, fields: fields }); showToast(res && res.queued ? t("offline") : t("sent")); closeCor(); } async function promoteToKaizen(){ var r = state.reports.find(function(x){ return String(x.id) === String(_corId); }); if (!r) return; var title = (r.type === "claim" ? "クレーム由来:" : "異常由来:") + String(pickF_(r,"body") || "").slice(0,24); var parts = []; if (r.cause) parts.push("原因:" + r.cause); if (r.countermeasure) parts.push("対策:" + r.countermeasure); if (!parts.length) parts.push(String(pickF_(r,"body") || "")); var k = { id: "rep_" + Date.now() + "_" + Math.random().toString(36).slice(2,6), type: "kaizen", title: title, place: "", severity: "", body: parts.join("\n"), effect: "", points: POINTS.kaizen, byName: (state.user && state.user.name) || "", byId: (state.user && state.user.id) || "", org: (state.user && state.user.org) || "", status: "open", response: "", createdAt: new Date().toISOString(), linkId: r.id }; state.reports.unshift(k); r.linkId = k.id; saveState(); renderAll(); try { var _tr = await translateReport({ title:k.title, body:k.body, effect:"" }); if (_tr && _tr.tr){ k.tr = JSON.stringify(_tr.tr); k.src = _tr.src || ""; saveState(); } } catch(e){} await submitReportToServer_(k); // 写真なしだが同じ経路で統一(将来写真が付いても安全) await gasMutate_({ action: "updateReport", reportId: r.id, fields: { linkId: k.id } }); showToast(t("promoted_ok")); closeCor(); switchTab("list"); } // ── 📅 異常・クレーム カレンダー(月グリッド) ── var _calYM = null, _calSel = null; function _ensureCalYM(){ if (!_calYM){ var n = new Date(); _calYM = { y:n.getFullYear(), m:n.getMonth() }; } } function calNav(delta){ _ensureCalYM(); _calSel = null; _calYM.m += delta; if (_calYM.m < 0){ _calYM.m = 11; _calYM.y--; } else if (_calYM.m > 11){ _calYM.m = 0; _calYM.y++; } renderCalendar(); } function qEventsByDay_(y,m){ var map = {}; (state.reports || []).filter(isQ_).forEach(function(r){ var dt = new Date(r.occurredAt || r.createdAt); if (isNaN(dt)) return; if (dt.getFullYear() !== y || dt.getMonth() !== m) return; (map[dt.getDate()] = map[dt.getDate()] || []).push(r); }); return map; } function calDay(y,m,d){ _calSel = { y:y, m:m, d:d }; renderCalDayList_(); var el = document.getElementById("cal-day-list"); if (el) el.scrollIntoView({ behavior:"smooth", block:"nearest" }); } function calOpen(id){ if (canManage_()) { openCor(id); return; } var r = (state.reports || []).find(function(x){ return String(x.id) === String(id); }); if (r){ var dt = new Date(r.occurredAt || r.createdAt); calDay(dt.getFullYear(), dt.getMonth(), dt.getDate()); } } function renderCalendar(){ _ensureCalYM(); var y = _calYM.y, m = _calYM.m; var mEl = document.getElementById("cal-month"); if (mEl) mEl.textContent = y + " / " + (m+1); var wdEl = document.getElementById("cal-wd"); var wd = WD[state.language] || WD.ja; if (wdEl) wdEl.innerHTML = wd.map(function(w){ return '
' + w + "
"; }).join(""); var startDow = new Date(y,m,1).getDay(); var days = new Date(y,m+1,0).getDate(); var byDay = qEventsByDay_(y,m); var nn = new Date(); var todayK = (nn.getFullYear()===y && nn.getMonth()===m) ? nn.getDate() : null; var cells = []; for (var i=0;i'); for (var dn=1; dn<=days; dn++){ var evs = byDay[dn] || []; var dots = evs.slice(0,3).map(function(r){ var ic = (r.urgent==="1"?"🚨":"") + (r.type==="claim"?"📣":"⚠"); var lbl = r.type==="claim" ? (r.custName || t("t_claim")) : (procLabel_(r.process) || t("t_ijou")); return '' + ic + " " + escapeHtml(lbl) + ""; }).join(""); if (evs.length > 3) dots += '+' + (evs.length-3) + ""; cells.push('
' + dn + "" + dots + "
"); } var body = document.getElementById("cal-body"); if (body) body.innerHTML = cells.join(""); renderCalDayList_(); } function renderCalDayList_(){ var el = document.getElementById("cal-day-list"); if (!el) return; _ensureCalYM(); var list; if (_calSel){ var byd = qEventsByDay_(_calSel.y,_calSel.m); list = byd[_calSel.d] || []; } else { list = (state.reports || []).filter(isQ_).filter(function(r){ var dt = new Date(r.occurredAt || r.createdAt); return !isNaN(dt) && dt.getFullYear()===_calYM.y && dt.getMonth()===_calYM.m; }); } if (!list.length){ el.innerHTML = '
' + t("cal_none") + "
"; return; } list.sort(function(a,b){ return String(b.occurredAt||b.createdAt).localeCompare(String(a.occurredAt||a.createdAt)); }); el.innerHTML = list.map(qCardHtml_).join(""); } function renderUrgentBanner_(){ var el = document.getElementById("urgent-banner"); if (!el) return; var open = (state.reports || []).filter(function(r){ return isQ_(r) && r.urgent === "1" && r.status !== "closed"; }); if (!open.length){ el.style.display = "none"; return; } el.style.display = "block"; el.innerHTML = '🚨 ' + open.length + " " + t("ban_urgent"); } // 📅 共有カレンダー(Googleカレンダー)の予定 → アプリで該当案件を直接開く // URL: ?open= でその異常/クレームの是正・対応へ/?tab=cal で📅一覧へ var _deepOpenId = null, _deepTab = null, _deepDone = false, _deepTabbed = false; function handleDeepLink_() { try { var q = new URLSearchParams(location.search); var openId = q.get('open'), tab = q.get('tab'); if (openId) { _deepOpenId = openId; try{ sessionStorage.setItem('safety_deepOpen', openId); }catch(e){} } else if (!_deepOpenId) { try{ var sv = sessionStorage.getItem('safety_deepOpen'); if (sv) _deepOpenId = sv; }catch(e){} } /* 自動更新の再読込でメモリ変数が消えても復元 */ if (tab === 'cal') _deepTab = 'cal'; if (openId || tab) history.replaceState(null, '', location.pathname); // 再読込で再発火しないよう掃除 tryDeepLink_(); } catch (e) {} } function tryDeepLink_() { if (_deepDone) return; if (!_deepOpenId && _deepTab !== 'cal') return; if (!_deepTabbed) { try { switchTab('cal'); } catch (e) {} _deepTabbed = true; } if (_deepOpenId) { var r = (state.reports || []).find(function (x) { return String(x.id) === String(_deepOpenId); }); if (r) { try { if (canManage_()) openCor(r.id); else calOpen(r.id); } catch (e) {} _deepDone = true; try{ sessionStorage.removeItem('safety_deepOpen'); }catch(e){} } // まだ同期されていなければ次のポーリング後に再試行(_deepDone は false のまま) } else { _deepDone = true; } } function renderReports() { const el = document.getElementById("rep-list"); let list = state.reports; if (curPerson) list = list.filter(r => (r.byName || "") === curPerson); if (curFilter === "open") list = list.filter(r => r.status === "open"); else if (curFilter !== "ALL") list = list.filter(r => r.type === curFilter); window._repFiltered = list; const personChip = curPerson ? `
` : ""; if (!list.length) { el.innerHTML = personChip + `
${t("no_reports")}
`; return; } el.innerHTML = personChip + list.slice(0, 100).map(r => { if (isQ_(r)) return qCardHtml_(r); const d = fmtDT_(r.createdAt); const isH = r.type === "hiyari"; const sevColor = r.severity === "高" ? "#e53935" : r.severity === "中" ? "#f57c00" : "#f5c518"; const head = isH ? `⚠️ ${escapeHtml(r.place || "")} ${escapeHtml(r.severity || "")}` : `💡 ${escapeHtml(pickF_(r,'title'))}`; const stCls = r.status === "open" ? "" : r.status === "rejected" ? "rejected" : "done"; let actions = ``; if (r.status === "open" && canManage_()) { if (isH) { actions = ``; } else { actions = `` + ``; } } // v1.8: 管理者(事務・社長)は削除可(テスト・誤報告の掃除) if (state.user && (state.user.role === "admin" || state.user.role === "president")) { actions += ``; } return `
${head}
${stLabel_(r)}
${escapeHtml(pickF_(r,'body'))}${pickF_(r,'effect') ? "
✨ " + escapeHtml(pickF_(r,'effect')) : ""} ${r.occurredAt ? "
🕐 " + escapeHtml(String(r.occurredAt).replace("T", " ")) : ""}
${t("by")}: ${escapeHtml(r.byName)}|${d} ${r.response ? `
✅ ${escapeHtml(pickResp_(r))}(${escapeHtml(r.respBy || "")})` : ""}
${repPhotosHtml_(r)}
${actions}
`; }).join(""); } // respond/adopt/passReport は上部の「対応モーダル」版(openResp→confirmResp)に統合済み(代理選択可) // ランキング(週次ストリーク方式・社長設計 2026-06-13) // 週1回以上の報告=3pt、連続週で3の累乗(2週=9, 3週=27, 4週以上=81でMAX) // 報告なし週=0pt、2週連続サボり→その週から毎週-3pt。提案採用=+10pt function weekKey_(d) { const dt = new Date(d); if (isNaN(dt)) return null; const day = (dt.getDay() + 6) % 7; // 月曜始まり const mon = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate() - day); return mon.getFullYear() + "-" + String(mon.getMonth() + 1).padStart(2, "0") + "-" + String(mon.getDate()).padStart(2, "0"); } function sevMult_(r) { // 危険度倍率(社長設計): 低=1, 中=1.5, 高=3。改善提案=1 if (r.type !== "hiyari") return 1; return r.severity === "高" ? 3 : r.severity === "中" ? 1.5 : 1; } function calcPoints_() { // 週分類: valid=採用/対応済みの報告あり(累乗が発動) // basic=報告はあるが未処理・見送りのみ(基本点のみ・連続は維持) // none=報告なし(streakリセット・2週目から-3) const byMember = {}; state.reports.forEach(r => { if (r.type === "claim") return; // クレーム一次対応はポイント対象外(隠蔽・抱え込み防止/品質管理推奨) const k = r.byName || "?"; if (!byMember[k]) byMember[k] = { weeks: {}, adopted: 0 }; const wk = weekKey_(r.createdAt); if (wk) { if (!byMember[k].weeks[wk]) byMember[k].weeks[wk] = { validMult: 0, anyMult: 0 }; const m = sevMult_(r); byMember[k].weeks[wk].anyMult = Math.max(byMember[k].weeks[wk].anyMult, m); if (r.status === "done" || r.status === "adopted") { byMember[k].weeks[wk].validMult = Math.max(byMember[k].weeks[wk].validMult, m); } } if (r.status === "adopted") byMember[k].adopted++; }); const nowWk = weekKey_(new Date()); const result = {}; Object.entries(byMember).forEach(([name, info]) => { const weeks = Object.keys(info.weeks).sort(); let pts = 0, streak = 0, missed = 0; if (weeks.length) { let cur = new Date(weeks[0]); const end = new Date(nowWk); while (cur <= end) { const wk = weekKey_(cur); const w = info.weeks[wk]; if (w && w.validMult > 0) { // 採用/対応済みの週 → 累乗発動 streak++; missed = 0; pts += Math.pow(3, Math.min(streak, 4)) * w.validMult; } else if (w) { // 報告はあるが未処理/見送り → 基本点のみ・連続は維持(増えも切れもしない) missed = 0; pts += 3 * w.anyMult; } else if (wk === nowWk) { break; // 今週はまだ途中=減点しない } else { missed++; if (missed >= 2) pts -= 3; streak = 0; } cur.setDate(cur.getDate() + 7); } } pts += info.adopted * POINTS.adopted; result[name] = { pts: Math.round(pts * 10) / 10, streak: streak }; }); return result; } function renderRanking() { const calc = calcPoints_(); const rows = Object.entries(calc).sort((a, b) => b[1].pts - a[1].pts); // 表彰台(上位3名) const pod = document.getElementById("podium"); if (pod) { if (!rows.length) { pod.innerHTML = `
${t("no_reports")}
`; } else { const order = [1, 0, 2]; // 2位・1位・3位の並び const hts = ["64px", "84px", "50px"]; const medals = ["🥈", "🥇", "🥉"]; pod.innerHTML = order.map((idx, pos) => { const e = rows[idx]; if (!e) return "
"; return `
${medals[pos]}
${escapeHtml(e[0])}
${e[1].pts}pt
`; }).join(""); } } const el = document.getElementById("rank-list"); if (!el) return; if (!rows.length) { el.innerHTML = `
${t("no_reports")}
`; return; } el.innerHTML = rows.slice(0, 20).map(([name, v], i) => { const medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : (i + 1); const fire = v.streak >= 2 ? ` 🔥${v.streak}${t("wk_streak")}` : ""; return `
${medal}
${escapeHtml(name)}${fire}
${v.pts} pt
`; }).join(""); } // 名前タップ → その人の報告履歴(改善履歴の見える化) let curPerson = null; function showHistory(name) { curPerson = name; switchTab("list"); } function clearPerson() { curPerson = null; renderFilters(); renderReports(); } async function deleteReport(id, name) { if (!confirm(t("confirm_delete_report") + (name || ""))) return; state.reports = (state.reports || []).filter(x => String(x.id) !== String(id)); saveState(); renderAll(); const r = await gasMutate_({ action: "deleteReport", reportId: id }); showToast(r.queued ? t("offline") : t("deleted")); } // v1.8: 集計 function summaryData_() { const all = state.reports || []; const hiyari = all.filter(r => r.type === "hiyari"); const kaizen = all.filter(r => r.type === "kaizen"); const sev = { 高:0, 中:0, 低:0 }; hiyari.forEach(r => { const x = String(r.severity || ""); if (x.indexOf("高") >= 0) sev.高++; else if (x.indexOf("中") >= 0) sev.中++; else if (x.indexOf("低") >= 0) sev.低++; }); const st = { open:0, done:0, adopted:0, rejected:0 }; all.forEach(r => { if (st[r.status] !== undefined) st[r.status]++; }); const months = {}; all.forEach(r => { const m = ymOf_(r.createdAt); if (m) months[m] = (months[m]||0)+1; }); const persons = {}; all.forEach(r => { const n = r.byName || "?"; persons[n] = (persons[n]||0)+1; }); const ijou = all.filter(r => r.type === "ijou"); const claim = all.filter(r => r.type === "claim"); const qOpen = all.filter(r => (r.type === "ijou" || r.type === "claim") && r.status !== "closed").length; return { total: all.length, hiyari: hiyari.length, kaizen: kaizen.length, ijou: ijou.length, claim: claim.length, qOpen: qOpen, sev: sev, st: st, months: months, persons: persons }; } function renderSummary_() { const el = document.getElementById("summary-box"); if (!el) return; const d = summaryData_(); if (!d.total) { el.innerHTML = '
まだ報告がありません
'; return; } const mk = Object.keys(d.months).sort().slice(-6); const topp = Object.entries(d.persons).sort((a,b)=>b[1]-a[1]).slice(0,5); const cell = (l,v,c) => `
${v}
${l}
`; let h = `
${cell("合計",d.total)}${cell("⚠️ヒヤリ",d.hiyari,"#c0392b")}${cell("💡改善",d.kaizen,"#1c87c9")}
`; h += `
${cell("🛑異常",d.ijou,"#d97706")}${cell("📣クレーム",d.claim,"#8e24aa")}${cell("🚨未完了",d.qOpen,d.qOpen?"#c0392b":"#1a7a3a")}
`; h += `
${cell("🔴高",d.sev.高,"#c0392b")}${cell("🟠中",d.sev.中,"#e8762c")}${cell("🟡低",d.sev.低,"#b59a00")}
`; h += `
${cell("対応待",d.st.open,"#c0392b")}${cell("対応済",d.st.done,"#1a7a3a")}${cell("採用",d.st.adopted,"#1a7a3a")}${cell("見送",d.st.rejected,"#889")}
`; h += `
📅 月別
`; h += mk.map(m => `
${m.replace("-","/")}${d.months[m]}件
`).join(""); h += `
🏅 報告件数トップ
`; h += topp.map(p => `
${escapeHtml(p[0])}${p[1]}件
`).join(""); el.innerHTML = h; } function exportSummaryCSV() { const d = summaryData_(); const rows = [["区分","項目","件数"]]; rows.push(["合計","全報告",d.total]); rows.push(["種別","ヒヤリハット",d.hiyari]); rows.push(["種別","改善提案",d.kaizen]); rows.push(["危険度","高",d.sev.高]); rows.push(["危険度","中",d.sev.中]); rows.push(["危険度","低",d.sev.低]); rows.push(["状況","対応待ち",d.st.open]); rows.push(["状況","対応済み",d.st.done]); rows.push(["状況","採用",d.st.adopted]); rows.push(["状況","見送り",d.st.rejected]); Object.keys(d.months).sort().forEach(m => rows.push(["月別",m,d.months[m]])); Object.entries(d.persons).sort((a,b)=>b[1]-a[1]).forEach(p => rows.push(["人別",p[0],p[1]])); const csv = "\ufeff" + rows.map(r => r.map(c => '"' + String(c==null?"":c).replace(/"/g,'""') + '"').join(",")).join("\r\n"); const blob = new Blob([csv], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "安全カイゼン集計_" + ymd_(new Date()) + ".csv"; a.click(); } function exportCSV() { const headers = ["ID", "種別", "報告日時", "発生/受付日時", "報告者", "組織", "場所工程/顧客", "内容", "効果", "危険度", "状態", "対象/区分", "出荷影響/緊急度", "緊急通知", "原因(真因)", "原因区分", "恒久対策(是正)", "担当部門", "対策期限", "効果確認", "水平展開", "対応内容", "対応者", "ポイント", "写真URL"]; const tyJa = { hiyari: "ヒヤリハット", kaizen: "改善提案", ijou: "異常発生", claim: "クレーム" }; const stJa = { open: "対応待ち", done: "対応済み", adopted: "採用", rejected: "見送り", occurred: "発生", firstaid: "応急処置済", investigating: "原因究明中", countering: "対策実施中", verifying: "効果確認中", closed: "完了" }; const PROC = { flexo:"フレキソ印刷",lami:"ラミネート",sheet:"シート製造",cut:"裁断・仕上",pack:"梱包・出荷",wh:"資材倉庫",other:"その他" }; const TGT = { machine:"設備・機械",product:"製品・半製品",material:"材料・フィルム",print:"印刷・インク",dim:"寸法・規格",foreign:"異物混入",other:"その他" }; const SHIP = { none:"影響なし",hold:"出荷保留",shipped:"出荷済み",delay:"出荷日ズレ" }; const CCAT = { quality:"品質不良",delivery:"納期遅延",qty:"数量相違",service:"対応・接客",other:"その他" }; const URG = { high:"高",mid:"中",low:"低" }; const DEPT = { mfg:"製造",machine:"機械保守",dev:"商品開発",qc:"品質管理",ga:"総務" }; const M4 = { man:"人(Man)",machine:"機械(Machine)",material:"材料(Material)",method:"方法(Method)" }; const placeOf = r => r.type === "hiyari" ? (r.place||"") : r.type === "kaizen" ? (r.title||"") : r.type === "ijou" ? (PROC[r.process]||r.process||"") : (r.custName||""); const tgtOf = r => r.type === "ijou" ? (TGT[r.target]||r.target||"") : r.type === "claim" ? (CCAT[r.claimCat]||r.claimCat||"") : ""; const impOf = r => r.type === "ijou" ? ([SHIP[r.shipImpact]||"", r.defectQty?("不良"+r.defectQty):"", (r.stopMin&&Number(r.stopMin)>0)?("停止"+r.stopMin+"分"):""].filter(Boolean).join(" ")) : r.type === "claim" ? (URG[r.urgency]||"") : ""; const rows = state.reports.map(r => [ r.id, tyJa[r.type] || r.type, fmtDTFull_(r.createdAt), (r.occurredAt || "").replace("T", " "), r.byName, r.org || "", placeOf(r), r.body || "", r.effect || (r.type==="claim"?(r.firstReply||""):(r.firstAid||"")), r.severity || "", stJa[r.status] || r.status, tgtOf(r), impOf(r), r.urgent==="1"?"🚨全員":"", r.cause || "", M4[r.causeType] || "", r.countermeasure || "", DEPT[r.cmDept] || "", (r.cmDue||"").replace("T"," "), r.effectResult==="ok"?"効果あり":r.effectResult==="ng"?"効果不十分":"", r.yokoNote || "", r.response || "", r.respBy || "", r.points || "", r.photoUrl || "" ]); const csv = "" + [headers, ...rows].map(r => r.map(c => '"' + String(c == null ? "" : c).replace(/"/g, '""') + '"').join(",")).join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "安全カイゼン報告_" + ymd_(new Date()) + ".csv"; a.click(); } // ============================================ // 画面・起動 // ============================================ /* 🔽 分類プルダウンは「選ぶ+自由入力」(input+datalist)。社長指示2026-06-29「プルダウンだけに依存しない」。 選んだ時=localizedラベルを英語キーに逆引きして多言語表示を維持/一覧に無い物=自由入力でそのまま保存。 */ const PROC_KEYS=['flexo','lami','sheet','cut','pack','wh','other']; const TGT_KEYS=['machine','product','material','print','dim','foreign','other']; const CCAT_KEYS=['quality','delivery','qty','service','other']; function comboKeyFromText_(prefix,keys,text){ var s=String(text||'').trim(); if(!s)return ''; for(var lang in T){ var d=T[lang]; if(!d)continue; for(var i=0;i'; }).join(''); } function fillSafetyCombos_(){ fillCombo_('ij-proc-list','proc_',PROC_KEYS,'proc'); fillCombo_('ij-target-list','tgt_',TGT_KEYS,'tgt'); fillCombo_('cl-cat-list','ccat_',CCAT_KEYS,'ccat'); } /* 📋 設定で選択肢を増減(場所工程/対象/区分)。標準(i18n)+設定で足した独自候補をコンボに合算。社長指示2026-06-29「設定から変えれるように」 */ var OPT_CATS_=[['proc','proc_',PROC_KEYS],['tgt','tgt_',TGT_KEYS],['ccat','ccat_',CCAT_KEYS]]; function renderOptEditor_(){ var box=document.getElementById('opt-edit-box'); if(!box)return; box.innerHTML=OPT_CATS_.map(function(c){ var cat=c[0],prefix=c[1],keys=c[2]; var builtin=keys.map(function(k){return t(prefix+k);}).map(function(v){ return ''+escapeHtml(v)+''; }).join(''); var extra=optExtra_(cat).map(function(v,ix){ return ''+escapeHtml(v)+' '; }).join(''); var add='+ '+t('opt_add')+''; return '
'+t('opt_cat_'+cat)+'
'+builtin+extra+add+'
'; }).join(''); } function addOpt_(cat){ var v=prompt(t('opt_add_prompt')); if(v===null)return; v=String(v).trim(); if(!v)return; state.optExtra=state.optExtra||{proc:[],tgt:[],ccat:[]}; state.optExtra[cat]=state.optExtra[cat]||[]; var keysMap={proc:PROC_KEYS,tgt:TGT_KEYS,ccat:CCAT_KEYS}, prefixMap={proc:'proc_',tgt:'tgt_',ccat:'ccat_'}; var dup=state.optExtra[cat].indexOf(v)>=0 || keysMap[cat].some(function(k){return t(prefixMap[cat]+k)===v;}); if(dup){ showToast('='); return; } state.optExtra[cat].push(v); saveState(); fillSafetyCombos_(); renderOptEditor_(); pushOptExtra_(); showToast(t('saved')||'保存しました'); } function removeOpt_(cat,ix){ var arr=(state.optExtra&&state.optExtra[cat])||[]; var v=arr[ix]; if(v===undefined)return; if(!confirm('✕ '+v+' ?'))return; arr.splice(ix,1); saveState(); fillSafetyCombos_(); renderOptEditor_(); pushOptExtra_(); } function pushOptExtra_(){ try{ gasMutate_({ action:'saveOpts', optExtra: state.optExtra }); }catch(e){} } // 全端末へ共有(失敗時はoutbox再送) function applyI18n() { document.querySelectorAll("[data-i18n]").forEach(el => { el.textContent = t(el.dataset.i18n); }); document.querySelectorAll("[data-i18n-ph]").forEach(el => { el.placeholder = t(el.dataset.i18nPh); }); fillSafetyCombos_(); renderOptEditor_(); } function renderLangGrids() { ["login-lang-grid", "lang-grid"].forEach(id => { const el = document.getElementById(id); if (!el) return; el.innerHTML = LANGS.map(([code, label]) => ``).join(""); }); } function setLang(code) { state.language = code; saveState(); applyI18n(); renderLangGrids(); // loginScreen が表示中でも applyI18n で data-i18n が効く if (currentUser) renderAll(); } function switchTab(tab) { document.querySelectorAll(".section").forEach(s => s.classList.remove("active")); document.getElementById("section-" + tab).classList.add("active"); document.querySelectorAll(".tab-btn").forEach(b => b.classList.toggle("active", b.dataset.tab === tab)); if (tab === "list") { renderFilters(); renderReports(); } if (tab === "cal") renderCalendar(); if (tab === "rank") renderRanking(); if (tab === "settings") renderRanking(); if (tab === "ai") renderAiChat(); } // ============================================ // 🤖 AI相談(危険対策の相談役・過去事例引き当て・👍👎・多言語) // AIの窓口は設備保全アプリのGAS(応答が読める・キー&AI方針設定済み)を共用。app='安全'で安全用ルールが適用される // ============================================ const SAFETY_AI_GAS = 'https://script.google.com/macros/s/AKfycbzJiwPtNGERWdn4-2ehL7K-Splz-6edlr6SnSva7ZgWRP9ZCFQSkuCLxvRXvrZCAjeT7g/exec'; const SAFETY_AI_TOKEN = 'kowa-setsubi-2b9f4c7a1e8d63a05f0719'; let safetyAIHistory = []; let safetyAIGreeted = false; const SAFE_AI_UI = { title:{ja:'🤖 AI相談(安全)',en:'🤖 AI (Safety)',vi:'🤖 AI An toàn',si:'🤖 AI ආරක්ෂාව',id:'🤖 AI Keselamatan',fil:'🤖 AI Kaligtasan',zh:'🤖 AI安全'}, desc:{ja:'危険・ヒヤリハット・改善のことを相談できます。過去の事例から対策を一緒に考えます。',en:'Ask about hazards, near-misses and improvements. I suggest measures based on past cases.',vi:'Hỏi về nguy hiểm, suýt tai nạn, cải tiến. Tôi gợi ý biện pháp dựa trên các vụ trước.',si:'අනතුරු/වැළකීම්/වැඩිදියුණු ගැන අසන්න. පැරණි සිදුවීම් අනුව පියවර යෝජනා කරමි.',id:'Tanya soal bahaya, nyaris celaka, perbaikan. Saran berdasarkan kasus lalu.',fil:'Magtanong tungkol sa panganib at improvement. Magmumungkahi base sa nakaraang kaso.',zh:'可咨询危险、险情、改善。根据过去案例一起想对策。'}, placeholder:{ja:'例:フォークリフト通路が危ない',en:'e.g., the forklift aisle is dangerous',vi:'VD: lối xe nâng nguy hiểm',si:'උදා: forklift මාර්ගය අනතුරුදායකයි',id:'mis: lorong forklift berbahaya',fil:'hal., delikado ang forklift aisle',zh:'例:叉车通道很危险'}, sending:{ja:'考え中…',en:'Thinking…',vi:'Đang nghĩ…',si:'සිතමින්…',id:'Berpikir…',fil:'Nag-iisip…',zh:'思考中…'}, err:{ja:'通信エラー。もう一度お試しください。',en:'Connection error. Try again.',vi:'Lỗi kết nối. Thử lại.',si:'සම්බන්ධතා දෝෂය. නැවත උත්සාහ කරන්න.',id:'Kesalahan koneksi. Coba lagi.',fil:'Error sa koneksyon. Subukan ulit.',zh:'连接错误,请重试。'} }; function safeAiUI(k){ const d=SAFE_AI_UI[k]||{}; return d[state.language]||d.ja; } function safeLangName(){ return ({ja:'日本語',en:'English',vi:'Vietnamese',si:'Sinhala',id:'Indonesian',fil:'Filipino',zh:'Chinese'})[state.language]||'日本語'; } function renderAiChat(){ const ti=document.getElementById('safe-ai-title'); if(ti) ti.textContent=safeAiUI('title'); const de=document.getElementById('safe-ai-desc'); if(de) de.textContent=safeAiUI('desc'); const inp=document.getElementById('safe-ai-input'); if(inp) inp.placeholder=safeAiUI('placeholder'); if(!safetyAIGreeted){ safetyAIGreeted=true; safeAIAppend('ai', safeAiUI('desc')); } } // 過去のヒヤリハット/改善から質問に近い事例を引き当て(bigram重なり+採用事例を優遇。事例が増えるほど精度UP) function safetyAIRelevant(q){ const reps=state.reports||[]; if(!reps.length) return []; const bg=function(s){ s=String(s||'').replace(/\s/g,'').toLowerCase(); var a=[]; for(var i=0;i=0)s++; }); if(String(r.status)==='adopted')s+=2; return {r:r,s:s}; }); scored.sort(function(a,b){ return b.s-a.s; }); var top=scored.filter(function(x){ return x.s>0; }).slice(0,12).map(function(x){ return x.r; }); if(!top.length) top=reps.slice(0,8); return top; } function safetyAICasesText(q){ var list=safetyAIRelevant(q); if(!list.length) return '(まだ過去の事例がありません)'; return list.map(function(r){ var kind=r.type==='kaizen'?'改善提案':'ヒヤリハット'; return '・['+kind+'] 場所:'+(r.place||'-')+' 危険度:'+(r.severity||'-')+' / 内容:'+(r.body||'-')+(r.response?' / 対策:'+r.response:' / 対策:未対応')+(String(r.status)==='adopted'?'(採用済)':''); }).join('\n'); } function safetyAISystem(q){ return [ 'あなたは工場の「安全の相談役」AIです。返答は必ず '+safeLangName()+' で、短く・具体的に・現場目線で。', '役割:危険・ヒヤリハット・改善の相談に乗り、下の自社の過去事例を根拠に対策を一緒に考える。', '重要:「これで安全」「絶対大丈夫」と断定しない。あくまで"対策の案"として出し、最終判断・実施は人(リーダー/社長)に委ねる。', '危険やヒヤリの相談には、最後に「🦺 アプリの『報告する』から記録してね」と促す(報連相)。', '', '【この会社の過去の事例(質問に近いもの・参考)】', safetyAICasesText(q), '', '安全・ヒヤリハット・改善と関係ない私的な雑談には答えず、'+safeLangName()+'で「ごめんね、安全・ヒヤリハット・改善のことだけ答えられます」と短く返す。' ].join('\n'); } async function safetyAICall(system, messages){ const res=await fetch(SAFETY_AI_GAS,{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'}, body:JSON.stringify({action:'ai',token:SAFETY_AI_TOKEN,app:'安全',system:system,messages:messages,max_tokens:800})}); const data=await res.json(); if(data&&data.error) throw new Error(data.error); return (data&&data.reply)?data.reply:safeAiUI('err'); } async function safeAISend(){ const inp=document.getElementById('safe-ai-input'); const text=(inp?inp.value:'').trim(); if(!text) return; inp.value=''; safeAIAppend('me',text); safeAIAppend('ai','…'); const st=document.getElementById('safe-ai-status'); if(st) st.textContent=safeAiUI('sending'); try{ const sys=safetyAISystem(text); const msgs=safetyAIHistory.concat([{role:'user',content:text}]).slice(-10); const reply=await safetyAICall(sys,msgs); safeAIReplaceLast(reply); safeAIAddFeedback(text,reply); safetyAIHistory.push({role:'user',content:text},{role:'assistant',content:reply}); if(safetyAIHistory.length>16) safetyAIHistory=safetyAIHistory.slice(-16); }catch(e){ safeAIReplaceLast(safeAiUI('err')); } if(st) st.textContent=''; } function safeAIAppend(who,text){ const box=document.getElementById('safe-ai-messages'); if(!box) return; const div=document.createElement('div'); div.style.cssText='max-width:88%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.55;white-space:pre-wrap;word-break:break-word;'+(who==='me'?'align-self:flex-end;background:#00a857;color:#fff;':'align-self:flex-start;background:#f0f2f5;color:#222;'); div.textContent=text; if(who==='ai') div.dataset.ai='1'; box.appendChild(div); box.scrollTop=box.scrollHeight; } function safeAIReplaceLast(text){ const box=document.getElementById('safe-ai-messages'); if(!box) return; const ai=box.querySelectorAll('[data-ai="1"]'); const last=ai[ai.length-1]; if(last) last.textContent=text; else safeAIAppend('ai',text); box.scrollTop=box.scrollHeight; } function safeAIAddFeedback(q,a){ const box=document.getElementById('safe-ai-messages'); if(!box) return; const row=document.createElement('div'); row.style.cssText='align-self:flex-start;display:flex;gap:10px;margin:-2px 0 6px 2px;'; const g=document.createElement('button'),b=document.createElement('button'); g.textContent='👍'; b.textContent='👎'; g.title='役に立った'; b.title='役に立たなかった'; g.style.cssText=b.style.cssText='border:none;background:none;cursor:pointer;font-size:17px;line-height:1;padding:2px;'; g.onclick=function(){ safeAISendFeedback(q,a,'good',row); }; b.onclick=function(){ safeAISendFeedback(q,a,'bad',row); }; row.appendChild(g); row.appendChild(b); box.appendChild(row); box.scrollTop=box.scrollHeight; } async function safeAISendFeedback(q,a,verdict,row){ try{ await fetch(SAFETY_AI_GAS,{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'}, body:JSON.stringify({action:'aifeedback',token:SAFETY_AI_TOKEN,app:'安全',user:(state.user&&state.user.name)||'',question:q,answer:a,verdict:verdict})}); }catch(e){} if(row) row.innerHTML=''+(verdict==='good'?t('ai_feedback_good'):t('ai_feedback_bad'))+''; } function renderAll() { renderFilters(); renderReports(); renderRanking(); renderPresence_(); renderLoginLog_(); renderSummary_(); renderCalendar(); renderUrgentBanner_(); updateSyncChip_(); } // ── v1.6 ログイン在席ランプ+履歴(全員可視) ── function deviceId_() { let d = localStorage.getItem("kowa_safety_device"); if (!d) { d = "d_" + Date.now().toString(36) + Math.random().toString(36).slice(2,6); localStorage.setItem("kowa_safety_device", d); } return d; } async function sendLoginEvent_() { if (!state.user) return; try { await gasFetch_({ action: "loginEvent", payload: JSON.stringify({ id: state.user.id || "", name: state.user.name || "", device: deviceId_() }) }); } catch (e) {} } function renderPresence_() { const el = document.getElementById("presence-list"); if (!el) return; const now = Date.now(); const online = (state.presence || []).map(p => { const last = p.lastSeen ? new Date(p.lastSeen).getTime() : 0; return { name: p.name || p.id, online: (now - last) < 3 * 60 * 1000, last: last }; }).filter(x => x.online).sort((a, b) => b.last - a.last); if (!online.length) { el.innerHTML = '
' + t("no_login") + '
'; return; } el.innerHTML = online.map(x => `🟢 ${escapeHtml(x.name)}` ).join(""); } function renderLoginLog_() { const el = document.getElementById("login-log"); if (!el) return; const logs = state.logins || []; if (!logs.length) { el.innerHTML = '
' + t("no_reports") + '
'; return; } el.innerHTML = logs.slice(0, 40).map(g => { const d = g.at ? fmtDT_(g.at) : ""; return `
${escapeHtml(g.name || g.id)}${d}
`; }).join(""); } function boot() { applyI18n(); renderLangGrids(); updateVersionBadge(); loadUser(); // loginScreen の言語グリッドを描画 const llg = document.getElementById('login-lang-grid'); if (llg) { llg.innerHTML = LANGS.map(([code, label]) => `` ).join(''); } if (!currentUser) { document.getElementById('loginScreen').style.display = 'flex'; document.getElementById('app').style.display = 'none'; initLogin(); return; } // 既にログイン済み(LocalStorage にセッションあり) document.getElementById('loginScreen').style.display = 'none'; document.getElementById('app').style.display = 'block'; state.user = currentUser; applyUserUI(); renderAll(); sendLoginEvent_(); startPoll_(); handleDeepLink_(); // 共有カレンダーの予定→該当案件へ(?open= / ?tab=cal) } document.querySelectorAll(".tab-btn").forEach(b => { b.onclick = () => switchTab(b.dataset.tab); }); boot();
処理中…
📖 マニュアルAI