⬅ 入口 🚚 横持ち 📖 マニュアル
📦

📦 材料注文

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

🔑 パスワードをお忘れの方は、社長・事務にご連絡ください
v4.1 (2026-06-22)
'; const w = window.open('', '_blank'); if (!w) { alert('ポップアップを許可してください'); return; } w.document.write(html); w.document.close(); } // 数量モーダル let qmItem = null; function openQtyModal(code) { qmItem = state.masters.find(m => m.code === code); if (!qmItem) return; document.getElementById("qm-name").textContent = qmItem.name; document.getElementById("qm-sub").textContent = (qmItem.code) + "|" + (qmItem.spec || "") + (qmItem.supplier ? "|" + qmItem.supplier : "") + (qmItem.lot ? "|ロット:" + qmItem.lot : ""); document.getElementById("qm-qty").value = qmItem.lot && Number(qmItem.lot) > 0 ? Number(qmItem.lot) : 1; document.getElementById("qm-unit").textContent = qmItem.unit || ""; document.getElementById("qm-note").value = ""; // v2.1: 外注手配なら専用入力を表示(チャーターは車格・行先も) const isSvc = (qmItem.cat || "") === "外注手配"; const isTruck = isSvc && String(qmItem.code).match(/^X000[0-9]$/); document.getElementById("svc-fields").style.display = isSvc ? "block" : "none"; document.getElementById("svc-truck-wrap").style.display = isTruck ? "block" : "none"; document.getElementById("svc-date").value = ""; document.getElementById("svc-dest").value = ""; document.getElementById("svc-ship").value = ""; document.getElementById("svc-arrive").value = ""; document.getElementById("qm-wish").value = ""; document.getElementById("qm-sched").value = window._calSched || ""; // 入口カレンダーから来た日付は「発注予定日(先付け予約)」に既定セット setAsap("qm", false); setAsap("svc", false); document.getElementById("qm-wish-block").style.display = isSvc ? "none" : "block"; document.getElementById("svc-date-row").style.display = isTruck ? "none" : "block"; _svcTruck = ""; document.querySelectorAll(".svc-truck-btn").forEach(b => b.classList.remove("active")); document.getElementById("qty-modal").classList.add("active"); } let _svcTruck = ""; document.addEventListener("click", (e) => { const b = e.target.closest(".svc-truck-btn"); if (!b) return; _svcTruck = b.dataset.v; document.querySelectorAll(".svc-truck-btn").forEach(x => x.classList.toggle("active", x === b)); }); function closeQtyModal() { document.getElementById("qty-modal").classList.remove("active"); } function qtyDelta(d) { const el = document.getElementById("qm-qty"); el.value = Math.max(1, (parseInt(el.value) || 1) + d); } // v2.3: 最短納期(ASAP)モード const _asap = { qm:false, svc:false, free:false, po:false }; const _WISH_ID = { qm:"qm-wish", svc:"svc-date", free:"free-when", po:"po-wish" }; function setAsap(k, on){ _asap[k]=on; const b=document.getElementById(k+"-asap"); if(b) b.classList.toggle("active", on); if(on){ const d=document.getElementById(_WISH_ID[k]); if(d) d.value=""; } } function toggleAsap(k){ setAsap(k, !_asap[k]); } function wishOf_(k){ if(_asap[k]) return "ASAP"; const d=document.getElementById(_WISH_ID[k]); return (d && d.value) || ""; } function wishDisp_(w){ return w==="ASAP" ? "最短" : (w ? String(w).replace(/-/g,"/") : ""); } // v5.2: 数量モーダルの入力を「素データ(src)」として読み取る(単発注文・カゴ追加で共用) function gatherQtySrc_() { if (!qmItem) return null; const qty = Math.max(1, parseInt(document.getElementById("qm-qty").value) || 1); const isSvc = (qmItem.cat || "") === "外注手配"; const isTruck = isSvc && String(qmItem.code).match(/^X000[0-9]$/); const src = { code: qmItem.code, name: qmItem.name, spec: qmItem.spec || "", unit: qmItem.unit || "", supplier: qmItem.supplier || "", supCode: qmItem.supCode || "", price: Number(qmItem.price) || 0, cat: qmItem.cat || "", qty, note: document.getElementById("qm-note").value.trim(), wish: isSvc ? "" : wishOf_("qm"), svcTruck: "", dest: "", shipDate: "", arriveDate: "" }; if (isSvc) { src.svcTruck = _svcTruck || ""; src.dest = document.getElementById("svc-dest").value.trim(); if (isTruck) { src.shipDate = document.getElementById("svc-ship").value || ""; src.arriveDate = document.getElementById("svc-arrive").value || ""; } else { src.wish = wishOf_("svc"); } } return src; } // v5.2: src → 注文レコード(単発・カゴ送信で共用。外注手配のメモ合成・withJa・社長承認判定はここで一元化) async function buildOrderObj_(src, batchId) { let note = src.note || ""; // 原文のまま(後で7言語に翻訳して双方向に) let svcWish = "", shipDate = src.shipDate || "", arriveDate = src.arriveDate || ""; if ((src.cat || "") === "外注手配") { const isTruck = String(src.code).match(/^X000[0-9]$/); const parts = []; if (src.svcTruck) parts.push("【車格】" + src.svcTruck); if (isTruck) { if (shipDate) parts.push("【出荷】" + shipDate.replace(/-/g, "/")); if (arriveDate) parts.push("【着荷】" + arriveDate.replace(/-/g, "/")); svcWish = shipDate || arriveDate || ""; } else { if (src.wish) { parts.push("【希望日】" + wishDisp_(src.wish)); svcWish = src.wish; } } if (src.dest) parts.push("【行先】" + src.dest); if (parts.length) note = parts.join(" ") + (note ? " " + note : ""); } // 🌐 双方向7言語:備考を翻訳(PO印刷用に日本語を基準に・読む人は各自の言語で読める) var _noteSrc = "", _noteTr = ""; if (note && note.trim()) { try { var _t = await trFields7({ note: note }, ['note']); if (_t && _t.tr) { _noteTr = JSON.stringify(_t.tr); _noteSrc = _t.src || ""; if (_t.tr.note && _t.tr.note.ja) note = _t.tr.note.ja; } } catch (e) {} } const price = Number(src.price) || 0; const amount = price * src.qty; const order = { id: "ord_" + Date.now() + "_" + Math.random().toString(36).slice(2, 6), code: src.code, name: src.name, spec: src.spec || "", unit: src.unit || "", supplier: src.supplier || "", supCode: src.supCode || "", cat: src.cat || "", qty: src.qty, note, src: _noteSrc, tr: _noteTr, amount: amount || 0, needsPresident: amount >= PRESIDENT_THRESHOLD, byName: state.user.name, byId: state.user.id || "", org: state.user.org || "", wishDate: ((src.cat || "") === "外注手配" ? svcWish : src.wish) || "", shipDate: shipDate || "", arriveDate: arriveDate || "", status: "pending", createdAt: new Date().toISOString() }; if (batchId) order.batchId = batchId; return order; } async function submitOrder() { const src = gatherQtySrc_(); if (!src) return; closeQtyModal(); const order = await buildOrderObj_(src); state.orders.unshift(order); saveState(); renderAll(); switchTab("status"); gasMutate_({ action: "submitOrder", order }).then(function (r) { showToast(r.queued ? t("offline") : t("ordered_toast")); }); } // 予約発注(先付け):発注予定日を指定して「予約」状態で保存。当日まで承認フローに進めない(メールも出ない)。 async function submitReserve() { const src = gatherQtySrc_(); if (!src) return; const sched = document.getElementById("qm-sched").value || (window._calSched || ""); if (!sched) { showToast(t("sched_need") || "発注予定日を入れてください"); return; } closeQtyModal(); const order = await buildOrderObj_(src); order.status = "予約"; order.schedDate = sched; state.orders.unshift(order); saveState(); renderAll(); switchTab("status"); gasMutate_({ action: "submitOrder", order }).then(function (r) { showToast(r.queued ? t("offline") : ("🗓 " + (t("reserved_toast") || "予約しました"))); }); } // 先行承認:予約を「承認」しても status は「予約」のまま(先付け保持)。reviewedBy/At だけ立てる。 async function approveReservation(id) { const o = state.orders.find(x => x.id === id); if (!o) return; if (!canApprove_(o)) { showToast(o.needsPresident ? (t("appr_need_pres") || "👑 社長承認が必要です") : (t("no_perm") || "承認権限がありません")); return; } const fields = { reviewedBy: state.user.name, reviewedAt: new Date().toISOString() }; // status は "予約" のまま Object.assign(o, fields); saveState(); renderAll(); const r = await gasMutate_({ action: "updateOrder", orderId: id, fields }); showToast(r.queued ? t("offline") : (t("resv_approved") || "✅ 予約を承認しました(先付けのまま)")); } // 予約を「発注に出す」:承認済なら発注書へ直行(status→approved)、未承認なら承認待ち(status→pending)。先付け日(schedDate)は保持。 async function releaseReserve(id) { const o = state.orders.find(x => x.id === id); if (!o) return; const preApproved = !!o.reviewedAt; let msg = preApproved ? (t("release_confirm_appr") || "📨 承認済みです。発注に出しますか?(発注書へ進みます)") : (t("release_confirm") || "📨 この予約を発注に出しますか?(承認フローに進みます)"); // ソフトガード:発注予定日より前なら一言警告(ロックはしない=急ぎは早期発注OK) if (o.schedDate && String(o.schedDate) > jstDateStr()) msg = (t("release_early") || "⚠ まだ発注予定日の前です。") + "\n" + msg; if (!confirm(msg)) return; o.status = preApproved ? "approved" : "pending"; // schedDate は消さない=先付けの記録は残す saveState(); renderAll(); await gasMutate_({ action: "updateOrder", orderId: id, fields: { status: o.status } }); showToast(t("released_toast") || "発注に出しました"); } // ============================================ // v5.2: まとめ注文(買い物カゴ)— 同じ仕入先に複数品目を一気に注文(社長指示2026-06-26) // 各品目は従来どおり1注文レコードになるので、承認・仕入先別発注書・カレンダー・CSV・入荷管理はすべて無改修で動く // ============================================ // v8: カゴタブのラベル(7言語)をTに追記 (function(){ var c={ ja:{cart_tab:"カゴ"}, en:{cart_tab:"Cart"}, vi:{cart_tab:"Giỏ"}, si:{cart_tab:"කූඩය"}, id:{cart_tab:"Keranjang"}, fil:{cart_tab:"Basket"}, zh:{cart_tab:"购物车"} }; try{ Object.keys(c).forEach(function(l){ if(T[l]) Object.assign(T[l], c[l]); }); }catch(e){} })(); const CART_KEY = "order_app_cart_v1"; function loadCart_() { try { return JSON.parse(localStorage.getItem(CART_KEY) || "[]") || []; } catch (e) { return []; } } function saveCart_() { try { localStorage.setItem(CART_KEY, JSON.stringify(cart)); } catch (e) {} } let cart = loadCart_(); // カゴに追加(数量モーダルの入力をそのまま積む。モーダルは閉じるがタブは「注文する」のまま=続けて選べる) function addToCart() { const src = gatherQtySrc_(); if (!src) return; cart.push(src); saveCart_(); closeQtyModal(); updateCartUI_(); showToast("🛒 " + t("cart_added") + "(" + cart.length + t("cart_unit") + ")"); } // v8: 一番下のタブ「🛒カゴ」の残数バッジを更新(ショッピングサイト風) function updateCartUI_() { const b = document.getElementById("cart-badge"); if (!b) return; if (cart.length > 0) { b.textContent = cart.length; b.style.display = ""; } else { b.textContent = ""; b.style.display = "none"; } } // カゴはタブになったので「開く」=カゴタブへ移動、「閉じる」=何もしない(後方互換で残す) function openCartModal() { if (typeof switchTab === "function") switchTab("cart"); else renderCart_(); } function closeCartModal() { /* タブ化により不要(後方互換のための空関数) */ } function cartQtyDelta_(idx, d) { if (!cart[idx]) return; cart[idx].qty = Math.max(1, (parseInt(cart[idx].qty) || 1) + d); saveCart_(); renderCart_(); } function cartSetQty_(idx, v) { if (!cart[idx]) return; cart[idx].qty = Math.max(1, parseInt(v) || 1); saveCart_(); updateCartUI_(); } function cartRemove_(idx) { cart.splice(idx, 1); saveCart_(); renderCart_(); updateCartUI_(); } // v8: カゴの品目ごとに希望納期を設定(🚀最短/指定日)=品目ごとに分別して発注できる function cartWishAsap_(idx) { if (!cart[idx]) return; cart[idx].wish = (cart[idx].wish === "ASAP") ? "" : "ASAP"; saveCart_(); renderCart_(); } function cartSetWishDate_(idx, v) { if (!cart[idx]) return; cart[idx].wish = v || ""; saveCart_(); renderCart_(); } function clearCart() { if (!cart.length) return; if (!confirm(t("cart_clear_q"))) return; cart = []; saveCart_(); renderCart_(); updateCartUI_(); closeCartModal(); } // カゴの中身を仕入先ごとにグループ表示(発注書が仕入先別にまとまるのと同じ見え方) function renderCart_() { const el = document.getElementById("cart-body"); if (!el) return; if (!cart.length) { el.innerHTML = `
${t("cart_empty")}
`; return; } // 仕入先ごとにまとめる(元の並び順を保つ) const groups = []; const gmap = {}; cart.forEach((c, idx) => { const sup = (c.supplier || "-").trim() || "-"; if (!gmap[sup]) { gmap[sup] = { sup, items: [] }; groups.push(gmap[sup]); } gmap[sup].items.push({ c, idx }); }); let total = 0; let html = ""; groups.forEach(g => { let gsum = 0; g.items.forEach(it => { gsum += (Number(it.c.price) || 0) * (Number(it.c.qty) || 0); }); total += gsum; html += `
🏢 ${escapeHtml(g.sup)} (${g.items.length}${t("cart_unit")})
`; g.items.forEach(it => { const c = it.c; const amt = (Number(c.price) || 0) * (Number(c.qty) || 0); const pres = amt >= PRESIDENT_THRESHOLD ? `${t("cart_president")}` : ""; const isSvc = (c.cat || "") === "外注手配"; // 外注手配は希望日をここで持たない(専用入力)=カゴの希望欄は出さない const asapOn = c.wish === "ASAP"; const dateVal = (c.wish && c.wish !== "ASAP") ? String(c.wish).slice(0, 10) : ""; const wishRow = isSvc ? "" : `
🗓 ${t("wish_when")}
`; html += `
${escapeHtml(c.name)}${pres}
${escapeHtml(c.code)}${c.spec ? "|" + escapeHtml(c.spec) : ""}${amt ? "|¥" + amt.toLocaleString() : ""}${c.note ? "|📝" + escapeHtml(c.note) : ""}
${escapeHtml(c.unit || "")}
${wishRow}
`; }); }); html += `
${t("cart_total")}:¥${total.toLocaleString()}
`; el.innerHTML = html; } // まとめて注文:カゴの各品目を1注文レコードにして送信(同一batchIdで「ひとまとめ」と分かるように) async function submitCart() { if (!cart.length) return; const items = cart.slice(); const batchId = "bat_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); const built = []; for (const src of items) built.push(await buildOrderObj_(src, batchId)); // 追加順に表示されるよう、後ろからunshift(built[0]=最初に入れた品目が上に来る) for (let i = built.length - 1; i >= 0; i--) state.orders.unshift(built[i]); cart = []; saveCart_(); saveState(); renderAll(); closeCartModal(); updateCartUI_(); switchTab("status"); let queued = 0; for (const order of built) { const r = await gasMutate_({ action: "submitOrder", order }); if (r.queued) queued++; } showToast(queued ? t("offline") : (t("cart_ordered") + "(" + built.length + t("cart_unit") + ")")); } // v2.2: 表にない物の自由注文(いつ・誰が・何を・なぜ) function openFreeModal() { document.getElementById("free-who").textContent = t("by") + ": " + (state.user ? state.user.name : "-"); ["free-what", "free-when", "free-why", "free-amt", "free-unit"].forEach(id => document.getElementById(id).value = ""); document.getElementById("free-qty").value = 1; setAsap("free", false); document.getElementById("free-modal").classList.add("active"); } async function submitFreeOrder() { const what = document.getElementById("free-what").value.trim(); const why = document.getElementById("free-why").value.trim(); if (!what && !why) { showToast(t("free_need")); return; } if (!what) { showToast(t("free_what") + " " + t("mark_req")); return; } if (!why) { showToast(t("free_why") + " " + t("mark_req")); return; } const qty = Math.max(1, parseInt(document.getElementById("free-qty").value) || 1); const unit = document.getElementById("free-unit").value.trim() || "個"; const when = wishOf_("free"); const est = Number(document.getElementById("free-amt").value) || 0; let note = "【理由】" + why; if (when) note += " 【希望】" + wishDisp_(when); if (est) note += " 【概算】¥" + est.toLocaleString(); const order = { id: "ord_" + Date.now() + "_" + Math.random().toString(36).slice(2, 6), code: "FREE", name: "📝 " + what, spec: "", unit: unit, supplier: "", supCode: "", qty, note, amount: est, needsPresident: est >= PRESIDENT_THRESHOLD, byName: state.user.name, byId: state.user.id || "", org: state.user.org || "", wishDate: when, status: "pending", createdAt: new Date().toISOString() }; document.getElementById("free-modal").classList.remove("active"); state.orders.unshift(order); saveState(); renderAll(); switchTab("status"); gasMutate_({ action: "submitOrder", order }).then(function (r) { showToast(r.queued ? t("offline") : t("ordered_toast")); }); } // 注文状況 // v3.6: "削除済み" は通常一覧から除外。ゴミ箱ビューを別途用意 const ST_KEYS = ["ALL", "予約", "pending", "approved", "ordered", "await_slip", "done", "cancelled", "rejected"]; // 🧹v7.7: フェーズ判定(入荷済を「納品書待ち」「完了(=納品書あり)」に分ける)& 完了の自動整理設定 function hasSlip_(o){ return !!(o && o.slipUrl && String(o.slipUrl).trim()); } function orderPhase_(o){ if(!o) return ""; if(o.status==="received") return hasSlip_(o)?"done":"await_slip"; return o.status; } function autoHideDays_(){ var v=parseInt(localStorage.getItem("order_autohide_days"),10); return isNaN(v)?30:v; } function setAutoHideDays_(v){ localStorage.setItem("order_autohide_days", String(parseInt(v,10)||0)); try{ renderStatusChips(); }catch(e){} try{ renderOrders(); }catch(e){} } function daysAgoStr_(n){ var d=new Date(); d.setDate(d.getDate()-n); var p=function(x){return ("0"+x).slice(-2);}; return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate()); } function renderStatusChips() { const el = document.getElementById("status-chips"); // "削除済み" を除いた注文のみカウント const activeOrders = state.orders.filter(o => o.status !== "削除済み"); el.innerHTML = ST_KEYS.map(k => { const label = k === "ALL" ? t("all") : (k === "予約" ? t("st_reserve") : (k === "done" ? t("reorder_chip") : t("st_" + k))); // 🔁v7.8: 「すべて」は進行中のみ(入荷済=納入済は再注文へ移すのでカウントから外す) const n = k === "ALL" ? activeOrders.filter(o => o.status !== "received").length : activeOrders.filter(o => orderPhase_(o) === k).length; const isActive = !showTrash && curStatus === k; return ``; }).join(""); // ゴミ箱ボタン(管理者のみ表示) const trashBtn = document.getElementById("trash-toggle-btn"); if (trashBtn) { const isAdmin = state.user && (state.user.role === "admin" || state.user.role === "president"); const trashCount = state.orders.filter(o => o.status === "削除済み").length; if (isAdmin && trashCount > 0) { trashBtn.className = "trash-toggle visible"; trashBtn.textContent = showTrash ? "◀ " + t("tab_status") + "に戻る" : t("tab_trash") + " (" + trashCount + ")"; } else { trashBtn.className = "trash-toggle"; } } } function normalView(k) { showTrash = false; curStatus = k; if (k !== "done") window._showAllReorder = false; renderStatusChips(); renderOrders(); } function toggleShowAllReorder_() { window._showAllReorder = true; renderOrders(); } function setStatus(k) { normalView(k); } // v3.6: ゴミ箱ビュー切替 function toggleTrashView() { showTrash = !showTrash; renderStatusChips(); renderOrders(); } // v3.6: 論理削除(admin/president → status を "削除済み" に変更・記録は残る) async function softDeleteOrder(id) { if (!state.user || (state.user.role !== "admin" && state.user.role !== "president")) return; const o = state.orders.find(x => x.id === id); if (!o) return; if (!confirm(t("del_logical_q"))) return; const fields = { status: "削除済み", cancelledBy: state.user.name, cancelledAt: jstStampStr() }; Object.assign(o, fields); saveState(); renderAll(); const r = await gasMutate_({ action: "updateOrder", orderId: id, fields }); showToast(r.queued ? t("offline") : "🗑 " + t("st_deleted") + "に移動しました"); } // v3.6: 復元(削除済み → 申請中に戻す) async function restoreOrder(id) { if (!state.user || (state.user.role !== "admin" && state.user.role !== "president")) return; const o = state.orders.find(x => x.id === id); if (!o) return; if (!confirm(t("restore_q"))) return; const fields = { status: "pending", cancelledBy: "", cancelledAt: "" }; Object.assign(o, fields); saveState(); renderAll(); const r = await gasMutate_({ action: "updateOrder", orderId: id, fields }); showToast(r.queued ? t("offline") : "♻ " + t("restore") + "しました"); } // v3.6: 完全削除(社長のみ・deleteOrder アクション → GAS 物理削除) async function deleteOrder(id) { if (!state.user || state.user.role !== "president") { alert("完全削除は社長のみ操作できます。"); return; } const o = state.orders.find(x => x.id === id); if (!o) return; if (!confirm(t("del_permanent_q"))) return; state.orders = state.orders.filter(x => x.id !== id); saveState(); renderAll(); const r = await gasMutate_({ action: "deleteOrder", orderId: id }); showToast(r.queued ? t("offline") : t("deleted_toast")); } // v2.5: キャンセル(記録は残す・状態をcancelledに) async function cancelOrder(id) { const o = state.orders.find(x => x.id === id); if (!o) return; if (!confirm(t("cancel_q"))) return; const reason = prompt(t("comment_opt")) || ""; const fields = { status: "cancelled", cancelledBy: state.user.name, cancelledAt: new Date().toISOString(), comment: reason }; Object.assign(o, fields); saveState(); renderAll(); const r = await gasMutate_({ action: "updateOrder", orderId: id, fields }); if (r.queued) showToast(t("offline")); } // 全アプリ共通の権限(共有名簿GASに保存・総務の「権限設定」で社長が編集) var _sharedPerms = null; async function loadSharedPerms() { try { var d = await authGas('getperms', {}); _sharedPerms = (d && d.perms) || {}; } catch (e) { if (!_sharedPerms) _sharedPerms = {}; } } function orderApprovers() { var v = _sharedPerms && _sharedPerms['order.approve']; var arr = (v && v.length) ? v.slice() : ['admin', 'leader']; if (arr.indexOf('president') < 0) arr.unshift('president'); return arr; } // 既定=社長・事務・リーダー(共有未取得でも従来どおり:リーダーは自組織のみ) function orderApproverUsers() { var v = _sharedPerms && _sharedPerms['order.approve.users']; return (v && v.length) ? v.slice().map(String) : []; } // 個別付与された人(総務の権限見える化で社長が付与) /* ===== 取引先メール自動(連絡先アプリの取引先から会社名で名寄せ) ===== */ const CONTACTS_GAS_URL='https://script.google.com/macros/s/AKfycbwwY4IYDa_IaGq-KRuGX_y4XD2rF4O2QPHddUbAYvzMUWD_Id-58Xlj7SiwhsunYp8VTA/exec'; var _pCompanies=[], _pContacts=[]; async function cGet_(table){ try{ var r=await fetch(CONTACTS_GAS_URL,{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'},body:JSON.stringify({action:'get',table:table,token:AUTH_TOKEN})}); var d=await r.json(); return Array.isArray(d)?d:[]; }catch(e){ return []; } } async function loadPartners(){ try{ _pCompanies=await cGet_('partner_companies'); _pContacts=await cGet_('partner_contacts'); }catch(e){} } function _normCo(s){ return String(s||'').replace(/株式会社|有限会社|(株)|\(株\)|㈱|(有)|\(有\)|㈲|\s| /g,'').toLowerCase(); } function supplierEmailFromContacts(sup){ if(!sup||!_pCompanies||!_pCompanies.length) return ''; var key=_normCo(sup); if(!key) return ''; var co=_pCompanies.find(function(c){ return _normCo(c.name)===key; }) || _pCompanies.find(function(c){ var n=_normCo(c.name); return n&&(n.indexOf(key)>=0||key.indexOf(n)>=0); }); if(!co) return ''; var cons=(_pContacts||[]).filter(function(p){ return String(p.companyId)===String(co.id) && p.email && String(p.retired)!=='true'; }); if(!cons.length) return ''; var primary=cons.find(function(p){ return String(p.isPrimary)==='true'||p.isPrimary===true; }); return (primary||cons[0]).email||''; } function canApprove_(o) { if (!state.user) return false; if (o.needsPresident) return state.user.role === "president"; // 10万円以上は社長のみ(不変) var allowed = orderApprovers(); // 承認できる役割=総務の「材料注文の承認」設定 if (allowed.indexOf(state.user.role) >= 0) { // リーダーは自分の組織の注文だけ承認できる(A案・1段承認・不変) if (state.user.role === "leader") return (o.org || "") === (state.user.org || ""); return true; } // 役割では不可でも、個別付与されていれば承認可(明示的な個人付与=組織縛りなし) if (orderApproverUsers().indexOf(String(state.user.id)) >= 0) return true; return false; } // v5.4: この人が「承認」にそもそも関われるか(まとめ承認画面を出すか) function canApproveAny_() { if (!state.user) return false; if (state.user.role === "president" || state.user.role === "admin") return true; if (orderApprovers().indexOf(state.user.role) >= 0) return true; if (orderApproverUsers().indexOf(String(state.user.id)) >= 0) return true; return false; } // v5.4: 注文の区分(cat)。古い注文はマスタからコード照合で補完 function catOfOrder_(o) { if (o.cat) return o.cat; var m = (state.masters || []).find(function (x) { return x.code === o.code; }); return (m && m.cat) || "その他"; } // v5.4: 1注文カードのHTML(一覧・まとめ承認グループで共用=挙動不変の抽出) function orderCardHtml_(o, isAdmin) { const d = (o.createdAt || "").slice(5, 16).replace("T", " "); let actions = ""; if (o.status === "pending" && canApprove_(o)) { actions += ``; actions += ``; } if (o.status === "approved" && isAdmin) { // v1.6: 承認の次は発注書(同じ仕入先の承認済みをまとめて選択済みで開く) actions += ``; actions += ``; } if (o.status === "ordered") { actions += ``; if (isAdmin) { actions += ``; } } if (o.status === "received" || o.status === "rejected" || o.status === "cancelled") { actions += ``; if (o.status === "received") { actions += ``; } } if (o.status === "予約") { // 先行承認:承認しても「予約(先付け)」のまま。発注予定日に「発注に出す」と承認済なら発注書へ直行。 if (!o.reviewedAt && canApprove_(o)) { actions += ``; actions += ``; } actions += ``; } // v3.6: 管理者(事務・社長)→ ✖取消(記録残す)+ 🗑削除済みへ移動(論理削除) if (isAdmin) { if (o.status === "pending" || o.status === "approved" || o.status === "ordered") { actions += ``; } actions += ``; } const crown = o.needsPresident && o.status === "pending" ? `
${t("need_president")}
` : ""; const cmt = o.comment ? `
💬 ${escapeHtml(o.comment)}
` : ""; const amt = o.amount ? `|¥${Number(o.amount).toLocaleString()}` : ""; const todayStr = jstDateStr(); let dueLine = ""; if (o.dueDate) { const late = o.status === "ordered" && String(o.dueDate) < todayStr; dueLine = `
🚚 ${t("due_label")}: ${String(o.dueDate).slice(5).replace("-", "/")}${late ? " " + t("delay") : ""}${o.dueNote ? `|${escapeHtml(o.dueNote)}` : ""}
`; } else if (o.wishDate) { const _wl = o.wishDate === "ASAP" ? "🚀 最短希望" : "希望: " + String(o.wishDate).slice(5).replace("-", "/"); dueLine = `
🚚 ${_wl}(回答待ち)
`; } if (o.poNo) { const _pm = o.poMethod === "email" ? " 📧メール送信済" : (o.poMethod === "print" ? " 🖨印刷発行" : ""); dueLine += `
📄 ${escapeHtml(o.poNo)}${_pm}
`; } const slip = (function(){ var ph=photoList_(o.slipUrl); if(!ph.length) return ""; return `
`+ph.map(function(p){ return ``; }).join('')+`
`; })(); return `
${escapeHtml(o.name)}
${o.status === "予約" ? t("st_reserve") : (o.status === "received" ? t("st_" + orderPhase_(o)) : t("st_" + o.status))}
${o.qty} ${escapeHtml(o.unit || "")}${amt}|${t("by")}: ${escapeHtml(o.byName)}${d}${o.supplier ? "
" + t("supplier") + ": " + escapeHtml(o.supplier) : ""}${o.note ? "
📝 " + escapeHtml(trPickField(o,'note',o.note)) : ""}
${(function(){ if(!o.schedDate) return ""; var _resv=o.status==="予約"; var _over=_resv&&String(o.schedDate)📅 ${t("sched_label")}: ${String(o.schedDate).slice(5).replace("-","/")}${_mk}${_appr?" ✅"+t("st_approved"):""}${o.wishDate?`|${o.wishDate==="ASAP"?"🚀":String(o.wishDate).slice(5).replace("-","/")}`:""}
`; })()}${dueLine}${slip}${crown}${cmt}
${actions}
`; } // v5.4: 承認待ちを「まとめて承認」グループで表示(社長:注文をまとめたのに承認で分別される問題の解消) let _apprGroupBy = "supplier"; function setApprGroup(g) { _apprGroupBy = g; renderOrders(); } function renderApprovalGroups_(pend, isAdmin) { const approvable = pend.filter(o => canApprove_(o)); const presWait = pend.length - approvable.length; const totalAll = approvable.reduce((s, o) => s + (Number(o.amount) || 0), 0); const U = t("cart_unit"); let html = `
📋 ${t("appr_group_title")}(${pend.length}${U})
${approvable.length ? `` : ""} ${presWait > 0 ? `
${t("appr_pres_wait").replace("{n}", presWait)}
` : ""}
`; const groups = []; const gmap = {}; pend.forEach(o => { const key = _apprGroupBy === "cat" ? catOfOrder_(o) : ((o.supplier || "-").trim() || "-"); if (!gmap[key]) { gmap[key] = { key, items: [] }; groups.push(gmap[key]); } gmap[key].items.push(o); }); window._apprGroups = groups; groups.forEach((g, gi) => { const ok = g.items.filter(o => canApprove_(o)); const sum = g.items.reduce((s, o) => s + (Number(o.amount) || 0), 0); const icon = _apprGroupBy === "cat" ? "🗂" : "🏢"; html += `
${icon} ${escapeHtml(g.key)} (${g.items.length}${U}・¥${sum.toLocaleString()})
${ok.length ? `` : ""}
${g.items.map(o => orderCardHtml_(o, isAdmin)).join("")}
`; }); return html; } function bulkApproveG_(gi) { const g = (window._apprGroups || [])[gi]; if (!g) return; bulkApprove(_apprGroupBy, g.key); } async function bulkApprove(scope, key) { const pend = state.orders.filter(o => o.status === "pending"); let target; if (scope === "all") target = pend; else if (scope === "cat") target = pend.filter(o => catOfOrder_(o) === key); else target = pend.filter(o => ((o.supplier || "-").trim() || "-") === key); const ok = target.filter(o => canApprove_(o)); const skipped = target.length - ok.length; if (!ok.length) { showToast(skipped ? t("appr_need_pres") : t("appr_none")); return; } const total = ok.reduce((s, o) => s + (Number(o.amount) || 0), 0); const label = scope === "all" ? t("appr_all_label") : key; let msg = t("appr_confirm").replace("{name}", label).replace("{n}", ok.length).replace("{amt}", "¥" + total.toLocaleString()); if (skipped > 0) msg += "\n" + t("appr_skip_note").replace("{n}", skipped); if (!confirm(msg)) return; const now = new Date(); const fields = { status: "approved", reviewedBy: state.user.name, reviewedAt: now.toISOString() }; ok.forEach(o => { Object.assign(o, fields); }); saveState(); refreshOrdersView_(); // v5.5: 重いカタログ再描画はしない=即反応 showToast(t("appr_done").replace("{n}", ok.length)); // v5.5: 送信は並列(JSONPは個別コールバック=並列安全)。逐次だと遅いGASで「件数×最大12秒」かかっていた=遅さの主因 const results = await Promise.all(ok.map(o => gasMutate_({ action: "updateOrder", orderId: o.id, fields: fields }))); if (results.some(r => r && r.queued)) showToast(t("offline")); // v5.5: 承認したら必ず発注書を出す(!queuedゲート廃止=通信が詰まっても発注書は開く。承認済みはローカルに反映済) const sups = [...new Set(ok.map(o => (o.supplier || "-").trim() || "-"))]; if (sups.length === 1) openPoModalFor(sups[0]); // 1社=その仕入先の発注書(1社1枚) else openPoModal(); // 複数社=仕入先を選んで発注書 } function renderOrders() { const el = document.getElementById("order-list"); const isAdmin = state.user && (state.user.role === "admin" || state.user.role === "president"); const isPresident = state.user && state.user.role === "president"; // v3.6: ゴミ箱ビュー if (showTrash) { const trashList = state.orders.filter(o => o.status === "削除済み"); if (!trashList.length) { el.innerHTML = `
${t("trash_empty")}
`; } else { el.innerHTML = trashList.slice(0, 100).map(o => { const d = (o.createdAt || "").slice(5, 16).replace("T", " "); const delAt = o.cancelledAt ? o.cancelledAt.slice(0, 16).replace("T", " ") : ""; const delBy = o.cancelledBy || ""; let actions = ""; // 復元:admin/president actions += ``; // 完全削除:社長のみ if (isPresident) { actions += ``; } return `
${escapeHtml(o.name)}
${t("st_deleted")}
${o.qty} ${escapeHtml(o.unit || "")}|${t("by")}: ${escapeHtml(o.byName)}${d}
${delAt ? `
🗑 ${delBy} / ${delAt}
` : ""}
${actions}
`; }).join(""); } return; } // 通常ビュー("削除済み" を除外) let list = state.orders.filter(o => o.status !== "削除済み"); // 🔁v7.8: 「すべて」は進行中のみ=入荷済(納入済=再注文の対象)は外す。各フェーズはorderPhase_で絞る if (curStatus === "ALL") list = list.filter(o => o.status !== "received"); else list = list.filter(o => orderPhase_(o) === curStatus); // 🧹 再注文(完了)ビューは、✅完了して一定日数がたった注文を自動で隠す(「すべて表示」で解除・データは履歴/経理に残る) let hiddenDone = 0; const _hd = autoHideDays_(); if (_hd > 0 && curStatus === "done" && !window._showAllReorder) { const _cut = daysAgoStr_(_hd); const _before = list.length; list = list.filter(o => { const _nd = (typeof nouhinDate_ === "function") ? nouhinDate_(o) : (o.receivedAt ? jdate_(o.receivedAt) : ""); return !(_nd && _nd < _cut); }); hiddenDone = _before - list.length; } const _hideNote = hiddenDone > 0 ? `
${t("autohide_note").replace("{n}", hiddenDone)}
` : ""; if (!list.length) { el.innerHTML = _hideNote || `
${t("no_orders")}
`; return; } // v5.4: 承認待ちは「まとめて承認」グループ表示(仕入先ごと/区分ごと+全部まとめて承認)。それ以外は従来の一覧 if (curStatus === "pending" && canApproveAny_()) { el.innerHTML = renderApprovalGroups_(list, isAdmin) + _hideNote; } else { el.innerHTML = list.slice(0, 100).map(o => orderCardHtml_(o, isAdmin)).join("") + _hideNote; } // バッジ const pend = state.orders.filter(o => o.status === "pending").length; const badge = document.getElementById("pending-badge"); if (badge) { badge.style.display = pend > 0 && isAdmin ? "inline-block" : "none"; badge.textContent = pend; } } async function reviewOrder(id, status) { const o = state.orders.find(x => x.id === id); if (!o) return; let comment = ""; if (status === "rejected") { if (!confirm(t("reject_q"))) return; comment = prompt(t("comment_opt")) || ""; } const sup = (o.supplier || "-").trim() || "-"; const fields = { status, comment, reviewedBy: state.user.name, reviewedAt: new Date().toISOString() }; Object.assign(o, fields); saveState(); refreshOrdersView_(); // v5.5: 重いカタログ再描画はしない=即反応 const r = await gasMutate_({ action: "updateOrder", orderId: id, fields }); if (r.queued) showToast(t("offline")); // v5.5: 承認して、その仕入先の承認待ちが無くなったら、すぐ発注書を出す(1社1枚・1件ずつ承認派にも届く) if (status === "approved") { const stillPending = state.orders.some(x => x.status === "pending" && ((x.supplier || "-").trim() || "-") === sup); if (!stillPending) openPoModalFor(sup); } } // ============================================ // v1.4: 発注書発行(管理者用・日本語) // 仕入先ごとに承認済み注文をまとめ、A4発注書を発行→同時に「発注済み」化 // ============================================ const COMPANY_INFO = { name: "有限会社興和化学工業所", addr: "〒910-0804 福井県福井市高木中央2丁目3319", tel: "TEL 0776-54-1191", fax: "FAX 0776-54-1587", emails: "kowa1@kowa-kagaku.co.jp / murosugi@kowa-kagaku.co.jp" }; function poTargets_() { return state.orders.filter(o => o.status === "approved"); } function openPoModal() { const sups = [...new Set(poTargets_().map(o => o.supplier || "-"))]; if (!sups.length) { showToast("承認済みの注文がありません(先に承認してください)"); return; } document.getElementById("po-supplier").innerHTML = sups.map(s => ``).join(""); document.getElementById("po-wish").value = ""; setAsap("po", false); document.getElementById("po-note").value = ""; renderPoItems(); document.getElementById("po-modal").classList.add("active"); } // v1.6: 承認済みカードから直接開く(その仕入先を選択済みで) function openPoModalFor(sup) { openPoModal(); const sel = document.getElementById("po-supplier"); if (sel && sup) { sel.value = sup; renderPoItems(); } } function renderPoItems() { const sup = document.getElementById("po-supplier").value; const items = poTargets_().filter(o => (o.supplier || "-") === sup); document.getElementById("po-items").innerHTML = items.map(o => { // v2.6: 各注文の希望(最短/日付)をバッジ表示 const w = o.wishDate === "ASAP" ? '🚀最短希望' : (o.wishDate ? '希望:' + String(o.wishDate).slice(5).replace("-", "/") + '' : ''); return ` `; }).join(""); // v8: 品目ごとの希望を尊重。全品目が同じ1つの日付の時だけ発注書の一括希望に既定セット。 // 「最短が混ざる/希望がバラバラ」の時は一括希望を空にする(各品目の希望を発注書の明細ごとに出す=全部最短にしない)。 const uniqWish = [...new Set(items.map(o => String(o.wishDate || "")).filter(Boolean))]; if (uniqWish.length === 1 && uniqWish[0] !== "ASAP") { setAsap("po", false); document.getElementById("po-wish").value = uniqWish[0].slice(0, 10); } else { setAsap("po", false); document.getElementById("po-wish").value = ""; } // v2.7: 仕入先メールを記憶から復元。無ければ v4.4: 連絡先アプリの取引先から自動引き込み var pe = document.getElementById("po-email"); if (pe) { var cached = localStorage.getItem("poEmail_" + sup) || ""; var fromC = cached ? "" : supplierEmailFromContacts(sup); pe.value = cached || fromC || ""; var src = document.getElementById("po-email-src"); if (src) src.textContent = (!cached && fromC) ? "📇 連絡先の取引先から自動入力(変更できます)" : ""; } } async function issuePO(method) { method = method || "print"; const sup = document.getElementById("po-supplier").value; const ids = Array.from(document.querySelectorAll(".po-ck:checked")).map(c => c.value); if (!ids.length) { showToast("品目を選んでください"); return; } const wish = wishOf_("po"); const note = document.getElementById("po-note").value.trim(); const email = (document.getElementById("po-email").value || "").trim(); if (method === "email" && !email) { showToast("仕入先メールを入力してください"); return; } const now = new Date(); const poNo = "PO" + now.getFullYear() + String(now.getMonth() + 1).padStart(2, "0") + String(now.getDate()).padStart(2, "0") + "-" + String(now.getHours()).padStart(2, "0") + String(now.getMinutes()).padStart(2, "0"); const orders = ids.map(id => state.orders.find(o => o.id === id)).filter(Boolean); document.getElementById("po-modal").classList.remove("active"); // 発行と同時に「発注済み」へ(希望納入日・発注書No・発行方法・日時を記録) for (const o of orders) { // v8: 品目ごとの希望を最優先(各注文の希望日は発注書の一括希望で上書きしない)。注文に希望が無い時だけ発注書の希望を補う。 const fields = { status: "ordered", orderedBy: state.user.name, orderedAt: now.toISOString(), wishDate: o.wishDate || wish || "", poNo: poNo, poMethod: method, poSentAt: now.toISOString(), supEmail: (method === "email" ? email : "") }; Object.assign(o, fields); const r = await gasMutate_({ action: "updateOrder", orderId: o.id, fields }); if (r.queued) showToast(t("offline")); } saveState(); renderAll(); if (method === "email") { localStorage.setItem("poEmail_" + sup, email); const r = await gasMutate_({ action: "sendPoEmail", poNo: poNo, supplier: sup, supplierEmail: email, wish: wish || "", note: note, issuedBy: state.user.name }); showToast(r.queued ? "📡 送信待ち(回復後にメール送信)" : "📧 発注書をメール送信しました"); } else { openPoWindow_(poNo, sup, orders, wish, note, now); showToast("🖨 発注書を発行しました"); } showToast("📄 " + poNo + " 発行・発注済みにしました"); } function openPoWindow_(poNo, sup, orders, wish, note, now) { const dateStr = now.getFullYear() + "年" + (now.getMonth() + 1) + "月" + now.getDate() + "日"; // v8: 品目ごとに希望が違う時は「品目ごと(下表)」と表示し、全部最短に潰さない const _uw = [...new Set(orders.map(o => String(o.wishDate || "")).filter(Boolean))]; const wishStr = _uw.length > 1 ? "品目ごと(下表「希望納期」欄をご確認ください)" : (_uw.length === 1 ? (_uw[0] === "ASAP" ? "最短でお願いします" : _uw[0].replace(/-/g, "/")) : (wish === "ASAP" ? "最短でお願いします" : (wish ? wish.replace(/-/g, "/") : "   年  月  日"))); // v5.7: 担当=この発注書の注文者(複数なら連名)。社長指示2026-06-26(絵文字はカラーコピー代になるので発注書からは外す) const tantou = [...new Set(orders.map(o => o.byName).filter(Boolean))].join("、") || state.user.name; // v2.4: チャーター便は「運送日程(出荷日→着荷日)」を表示 const charterOrders = orders.filter(o => o.shipDate || o.arriveDate); const fmtJ = (d) => d ? String(d).replace(/-/g, "/") : "  /  "; const wishBox = charterOrders.length ? `
運送日程
${charterOrders.map(o => `出荷 ${fmtJ(o.shipDate)} → 着荷 ${fmtJ(o.arriveDate)}`).join("
")}
貴社 ご確認欄:
日程・車両に問題があればご一報ください
` : `
希望納入日:${wishStr} 貴社 納期ご回答欄:   月   日
恐れ入りますが納入予定日をご記入・ご連絡ください
`; const wishCell_ = (o) => o.wishDate === "ASAP" ? '最短' : (o.wishDate ? String(o.wishDate).replace(/-/g, "/") : "-"); const rows = orders.map((o, i) => `${i + 1}${escapeHtml(o.code)}` + `${escapeHtml(o.name)}${o.spec ? '
' + escapeHtml(o.spec) + "" : ""}` + `${o.qty}${escapeHtml(o.unit || "")}` + `${wishCell_(o)}` + `${escapeHtml(o.note || "")}`).join(""); const body = `

発注書

${escapeHtml(sup)} 御中
いつもお世話になっております。
下記の通り発注いたします。
発行日:${dateStr}
発注書番号:${poNo}
${COMPANY_INFO.name}
${COMPANY_INFO.addr}
${COMPANY_INFO.tel} ${COMPANY_INFO.fax}
Email: ${COMPANY_INFO.emails}
担当:${escapeHtml(tantou)} 
${rows}
No品目コード品名・規格数量単位希望納期備考
${wishBox} ${note ? `
備考:${escapeHtml(note)}
` : ""}
納期のご回答先(お手数ですがご一報ください)
${COMPANY_INFO.tel} / ${COMPANY_INFO.fax} / Email: ${COMPANY_INFO.emails}
材料注文アプリ ${poNo} より発行
`; showPoPrint_(poNo, body); } // v5.6: 発注書は「画面内オーバーレイ」で表示(別ウィンドウのポップアップは出ない/迷子になる/await後はブロックされるため)。 // 印刷は @media print でこの発注書だけを出す(迷子にならず確実に印刷/PDF保存できる) function showPoPrint_(poNo, bodyHtml) { if (!document.getElementById("po-print-css")) { const st = document.createElement("style"); st.id = "po-print-css"; st.textContent = '#po-print-host{position:fixed;inset:0;z-index:100000;background:#525659;overflow:auto;}' + '.po-print-bar{position:sticky;top:0;display:flex;gap:10px;justify-content:center;padding:10px;background:#2b2f33;box-shadow:0 2px 8px rgba(0,0,0,.4);}' + '.po-print-bar button{padding:11px 22px;border:none;border-radius:8px;font-size:14px;font-weight:800;cursor:pointer;}' + '.po-print-bar .pp-go{background:#1a3a6e;color:#fff;}.po-print-bar .pp-close{background:#d9dde2;color:#222;}' + '#po-print-area{background:#fff;max-width:200mm;margin:14px auto 40px;padding:14mm;box-shadow:0 2px 16px rgba(0,0,0,.45);color:#222;font-family:"Yu Gothic",Meiryo,sans-serif;font-size:11pt;line-height:1.6;}' + '#po-print-area h1{font-size:21pt;text-align:center;letter-spacing:.8em;border-bottom:3px double #222;padding-bottom:6px;margin:0 0 14px;}' + '#po-print-area table{width:100%;border-collapse:collapse;font-size:10.5pt;}' + '#po-print-area th,#po-print-area td{border:1px solid #555;padding:6px 8px;}' + '#po-print-area th{background:#eef2f7;}#po-print-area .nb td{border:none;padding:2px 4px;}' + '@media print{@page{size:A4 portrait;margin:14mm;}body.po-printing>*:not(#po-print-host){display:none!important;}' + '#po-print-host{position:static;background:#fff;overflow:visible;}.po-print-bar{display:none!important;}' + '#po-print-area{box-shadow:none;margin:0;max-width:none;padding:0;}}'; document.head.appendChild(st); } closePoPrint_(); const host = document.createElement("div"); host.id = "po-print-host"; host.innerHTML = '
' + '' + '' + '
' + bodyHtml + '
'; document.body.appendChild(host); document.body.classList.add("po-printing"); host.scrollTop = 0; } function closePoPrint_() { const h = document.getElementById("po-print-host"); if (h) h.remove(); document.body.classList.remove("po-printing"); } // v1.2: 「発注した」→ 納期入力モーダル(Googleカレンダーへ自動転記) let _dueOrderId = null; let _dueIsChange = false; function markOrdered(id) { openDueModal(id, false); } function openDueModal(id, isChange) { const o = state.orders.find(x => x.id === id); if (!o) return; _dueOrderId = id; _dueIsChange = !!isChange; document.getElementById("due-sub").textContent = o.name + " ×" + o.qty + (o.unit || "") + (o.supplier ? "|" + o.supplier : ""); document.getElementById("due-date").value = o.dueDate || ""; document.getElementById("due-note").value = o.dueNote || ""; document.getElementById("due-skip-btn").style.display = isChange ? "none" : "block"; document.getElementById("due-modal").classList.add("active"); } async function saveDue(skip) { const o = state.orders.find(x => x.id === _dueOrderId); if (!o) return; document.getElementById("due-modal").classList.remove("active"); const dueDate = skip ? "" : document.getElementById("due-date").value; const dueNote = skip ? "" : document.getElementById("due-note").value.trim(); const fields = _dueIsChange ? { dueDate, dueNote } : { status: "ordered", orderedBy: state.user.name, orderedAt: new Date().toISOString(), dueDate, dueNote }; Object.assign(o, fields); saveState(); renderAll(); const r = await gasMutate_({ action: "updateOrder", orderId: _dueOrderId, fields }); if (r.queued) showToast(t("offline")); else if (dueDate) showToast("📅 " + t("due_label") + " " + dueDate + " → Google Calendar ✓"); } async function markReceived(id) { if (!confirm(t("recv_q"))) return; const o = state.orders.find(x => x.id === id); if (!o) return; const fields = { status: "received", receivedBy: state.user.name, receivedAt: new Date().toISOString() }; Object.assign(o, fields); saveState(); renderAll(); const r = await gasMutate_({ action: "updateOrder", orderId: id, fields }); if (r.queued) showToast(t("offline")); // v1.2: 納品書の写真を促す openSlipModal(id); } // ── v1.2: 納品書写真(Drive保存・経理の見える化) ── let _slipOrderId = null; /* ===== 複数写真(社長指示2026-06-25・全アプリ標準)。納品書写真を複数→slipUrlに1セルで保存(GASはUPDATABLEにslipUrl追加のみ) ===== */ let pendingPhotos = []; function photoList_(p){ if(!p)return[]; var s=String(p); if(s.charAt(0)==='['){ try{ var a=JSON.parse(s); return Array.isArray(a)?a.filter(Boolean):[]; }catch(e){ return [s]; } } return [s]; } function driveImg_(u,size){ if(!u)return''; var s=String(u); if(s.indexOf('data:')===0)return s; var m=s.match(/(?:[?&]id=|\/d\/)([-\w]{20,})/); if(m)return 'https://drive.google.com/thumbnail?id='+m[1]+'&sz=w'+(size||1000); return s; } function compressDataUrl_(dataUrl,maxDim,quality){ return new Promise(function(res){ try{ var img=new Image(); img.onload=function(){ var w=img.width,h=img.height; if(w>h&&w>maxDim){h=Math.round(h*maxDim/w);w=maxDim;} else if(h>=w&&h>maxDim){w=Math.round(w*maxDim/h);h=maxDim;} var c=document.createElement('canvas');c.width=w;c.height=h;c.getContext('2d').drawImage(img,0,0,w,h);res(c.toDataURL('image/jpeg',quality)); }; img.onerror=function(){res(dataUrl);}; img.src=dataUrl; }catch(e){res(dataUrl);} }); } async function compressTo_(dataUrl,budget){ if(!dataUrl||dataUrl.length<=budget)return dataUrl||''; var tries=[[700,0.55],[560,0.5],[460,0.45],[380,0.42],[300,0.4],[240,0.38]]; for(var i=0;i48000&&out.length>1){out.pop();s=JSON.stringify(out);} return s; } function renderSlipGallery_(){ var g=document.getElementById('slip-preview'); if(!g)return; g.innerHTML='
'+pendingPhotos.map(function(p,i){ return '
'; }).join('')+'
'; } function removeSlipPhoto_(i){ pendingPhotos.splice(i,1); renderSlipGallery_(); } function orderImg_(srcs){ var ov=document.createElement('div'); ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:100000;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding:14px 10px;overflow-y:auto;gap:10px;'; var close=function(){ if(ov.parentNode)ov.parentNode.removeChild(ov); }; ov.onclick=close; (srcs||[]).forEach(function(s){ var im=document.createElement('img'); im.style.cssText='max-width:100%;max-height:90vh;object-fit:contain;border-radius:6px;'; im.src=driveImg_(s,1600); ov.appendChild(im); }); var x=document.createElement('div'); x.textContent='✕'; x.style.cssText='position:fixed;top:10px;right:16px;color:#fff;font-size:26px;font-weight:bold;cursor:pointer;'; ov.appendChild(x); document.body.appendChild(ov); } function showSlip_(id){ var o=state.orders.find(function(x){return x.id===id;}); if(o)orderImg_(photoList_(o.slipUrl)); } function openSlipModal(id) { const o = state.orders.find(x => x.id === id); if (!o) return; _slipOrderId = id; document.getElementById("slip-sub").textContent = o.name + "|" + (o.supplier || ""); document.getElementById("slip-file").value = ""; pendingPhotos = photoList_(o.slipUrl || ""); renderSlipGallery_(); try { _slipOcrData = o.slipData ? JSON.parse(o.slipData) : null; } catch (e) { _slipOcrData = null; } try { renderOcrEdit_(); } catch (e) {} document.getElementById("slip-modal").classList.add("active"); } function closeSlipModal() { document.getElementById("slip-modal").classList.remove("active"); _slipOcrData = null; var _ob = document.getElementById("slip-ocr"); if (_ob) { _ob.style.display = "none"; _ob.innerHTML = ""; } } function slipFileChosen(ev) { const files = ev.target.files; if (!files || !files.length) return; const MAX = 6; let i = 0; (function next() { if (i >= files.length || pendingPhotos.length >= MAX) { ev.target.value = ""; renderSlipGallery_(); return; } const f = files[i++]; const reader = new FileReader(); reader.onload = async () => { let d = reader.result; try { d = await compressDataUrl_(d, 1100, 0.72); } catch (_) {} pendingPhotos.push(d); renderSlipGallery_(); next(); }; reader.onerror = () => next(); reader.readAsDataURL(f); })(); } async function slipSubmit_() { if (!_slipOrderId) return; const oid = _slipOrderId; const o = state.orders.find(x => x.id === oid); let sd = null; try { sd = collectOcrEdit_(); } catch (e) {} const sdStr = sd ? JSON.stringify(sd) : (o && o.slipData ? o.slipData : ''); // 🔧v7.6: 写真は Drive にPOSTアップロードして「短いURL」だけを保存(JSONPのURL長制限で共有失敗していたのを根治) let slipUrlVal = (o && o.slipUrl) || ''; let uploadFailed = false; try { if (window.showBusy) window.showBusy('💾 納品書を保存中…'); } catch (e) {} try { if (pendingPhotos.length) { const urls = []; for (let i = 0; i < pendingPhotos.length; i++) { const p = pendingPhotos[i]; if (String(p).indexOf('data:') !== 0) { urls.push(p); continue; } // 既存のDrive URLは再アップしない const u = await slipUploadOne_(oid, o, p); if (u) urls.push(u); } slipUrlVal = urls.length === 1 ? urls[0] : (urls.length ? JSON.stringify(urls) : ''); } else { slipUrlVal = ''; // 写真を全部消した } } catch (e) { uploadFailed = true; } try { if (window.hideBusy) window.hideBusy(); } catch (e) {} if (uploadFailed) { if (o) { o.slipData = sdStr; } saveState(); renderAll(); showToast('⚠ 電波が不安定で共有保存できませんでした。電波の良い所でもう一度「保存」してください'); return; // モーダルは閉じない=そのまま再保存できる } if (o) { o.slipUrl = slipUrlVal; o.slipData = sdStr; } saveState(); renderAll(); closeSlipModal(); const fields = { slipUrl: slipUrlVal }; if (sd) fields.slipData = sdStr; const r = await gasMutate_({ action: "updateOrder", orderId: oid, fields: fields }); showToast(r && r.queued ? t("offline") : t("slip_sent")); } function reorder(id) { const o = state.orders.find(x => x.id === id); if (!o) return; const m = state.masters.find(x => x.code === o.code); if (!m) { showToast(t("no_items")); return; } openQtyModal(m.code); document.getElementById("qm-qty").value = o.qty; switchTab("order"); } // CSV function exportOrdersCSV() { const headers = ["注文ID", "発注書No", "日時", "申請者", "品目コード", "品目名", "規格", "数量", "単位", "仕入先", "金額", "状態", "希望納入日", "入荷予定", "予定メモ", "入荷日", "納品書URL", "承認者", "発注者", "入荷確認", "メモ", "コメント"]; const stJa = { pending: "承認待ち", approved: "承認済", ordered: "発注済", received: "入荷済", rejected: "却下" }; const rows = state.orders.map(o => [ o.id, o.poNo || "", (o.createdAt || "").replace("T", " ").slice(0, 16), o.byName, o.code, o.name, o.spec, o.qty, o.unit, o.supplier, o.amount || "", stJa[o.status] || o.status, (o.wishDate === "ASAP" ? "最短" : o.wishDate || ""), o.dueDate || "", o.dueNote || "", jdate_(o.receivedAt), o.slipUrl || "", o.reviewedBy || "", o.orderedBy || "", o.receivedBy || "", o.note || "", o.comment || "" ]); 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 = "材料注文履歴_" + jstDateStr() + ".csv"; a.click(); } // ============================================ // v1.2: カレンダー(納期🟠・入荷🟢・遅延🔴の見える化) // ============================================ let calY = new Date().getFullYear(); let calM = new Date().getMonth(); // 0-indexed let calSelDay = null; function calNav(d) { calM += d; if (calM < 0) { calM = 11; calY--; } if (calM > 11) { calM = 0; calY++; } calSelDay = null; renderCalendar(); } function ordersOnDay_(dateStr) { return state.orders.filter(o => (o.dueDate && String(o.dueDate).slice(0, 10) === dateStr) || (o.receivedAt && jdate_(o.receivedAt) === dateStr) ); } // v1.3: 今後の入荷予定一覧(発注済で予定日あり・遅延を先頭に赤表示) function renderUpcoming_() { const card = document.getElementById("upcoming-card"); const el = document.getElementById("upcoming-list"); if (!card || !el) return; const todayStr = jstDateStr(); const list = state.orders .filter(o => o.status === "ordered" && o.dueDate) .sort((a, b) => String(a.dueDate).localeCompare(String(b.dueDate))); if (!list.length) { card.style.display = "none"; return; } card.style.display = "block"; el.innerHTML = list.slice(0, 15).map(o => { const ds = String(o.dueDate).slice(0, 10); const late = ds < todayStr; const md = ds.slice(5).replace("-", "/"); return `
${md}${late ? "
" + t("delay") + "" : ""}
${escapeHtml(o.name)}
${o.qty}${escapeHtml(o.unit || "")}|${escapeHtml(o.supplier || "")}${o.dueNote ? "|📝" + escapeHtml(o.dueNote) : ""}
`; }).join(""); } function renderCalendar() { renderUpcoming_(); document.getElementById("cal-title").textContent = calY + "/" + (calM + 1); const first = new Date(calY, calM, 1); const days = new Date(calY, calM + 1, 0).getDate(); const startDow = first.getDay(); const todayStr = jstDateStr(); const wd = ["日", "月", "火", "水", "木", "金", "土"]; let html = '
'; wd.forEach((w, i) => { html += `
${w}
`; }); for (let i = 0; i < startDow; i++) html += "
"; for (let day = 1; day <= days; day++) { const ds = calY + "-" + String(calM + 1).padStart(2, "0") + "-" + String(day).padStart(2, "0"); const os = ordersOnDay_(ds); const dues = os.filter(o => o.dueDate && String(o.dueDate).slice(0, 10) === ds && o.status !== "received"); const recvs = os.filter(o => o.receivedAt && jdate_(o.receivedAt) === ds); const late = dues.some(o => o.status === "ordered" && ds < todayStr); let dots = ""; if (dues.length) dots += `●${dues.length}`; if (recvs.length) dots += `●${recvs.length}`; const isToday = ds === todayStr; const isSel = ds === calSelDay; html += `
${day}
${dots}
`; } html += "
"; document.getElementById("cal-grid").innerHTML = html; renderCalDayList(); } function calSelect(ds) { calSelDay = ds; renderCalendar(); } function renderCalDayList() { const el = document.getElementById("cal-day-list"); if (!calSelDay) { el.innerHTML = ""; return; } const os = ordersOnDay_(calSelDay); if (!os.length) { el.innerHTML = `
${calSelDay}
`; return; } el.innerHTML = `
📅 ${calSelDay}
` + os.map(o => { const isRecv = o.receivedAt && jdate_(o.receivedAt) === calSelDay; return `
${isRecv ? "✅" : "🚚"} ${escapeHtml(o.name)}
${t("st_" + o.status)}
${o.qty}${escapeHtml(o.unit || "")}|${escapeHtml(o.supplier || "")}|${t("by")}: ${escapeHtml(o.byName)}${o.dueNote ? "
📝 " + escapeHtml(o.dueNote) : ""}
${o.slipUrl ? `${t("slip_view")} ↗` : ""}
`; }).join(""); } // ============================================ // v1.2: 仕入先別 月次集計(経理用) // ============================================ let sumY = new Date().getFullYear(); let sumM = new Date().getMonth(); function sumNav(d) { sumM += d; if (sumM < 0) { sumM = 11; sumY--; } if (sumM > 11) { sumM = 0; sumY++; } renderSummary(); } // v1.8: 発注残一覧(発注済み・未入荷。入荷予定の近い順・遅延は赤) function renderZan_() { const el = document.getElementById("zan-table"); if (!el) return; const todayStr = jstDateStr(); const list = state.orders.filter(o => o.status === "ordered") .sort((a, b) => String(a.dueDate || (a.wishDate === "ASAP" ? "0000-00-00" : a.wishDate) || "9999").localeCompare(String(b.dueDate || (b.wishDate === "ASAP" ? "0000-00-00" : b.wishDate) || "9999"))); if (!list.length) { el.innerHTML = '
発注残はありません ✓
'; return; } const totalAmt = list.reduce((s, o) => s + (Number(o.amount) || 0), 0); let html = ``; list.forEach(o => { const due = o.dueDate ? String(o.dueDate).slice(5).replace("-", "/") : (o.wishDate === "ASAP" ? "希望最短" : (o.wishDate ? "希望" + String(o.wishDate).slice(5).replace("-", "/") : "未定")); const late = o.dueDate && String(o.dueDate) < todayStr; html += ``; }); html += `
品目数量 入荷予定金額
${escapeHtml(String(o.name).slice(0, 16))}
${escapeHtml(o.supplier || "")}
${o.qty}${escapeHtml(o.unit || "")} ${due}${late ? "
⚠遅延" : ""}
${o.amount ? "¥" + Number(o.amount).toLocaleString() : "-"}
合計 ${list.length}件 ¥${totalAmt.toLocaleString()}
`; el.innerHTML = html; } // v1.8: 月別推移(直近6ヶ月・件数/入荷/金額) function renderTrend_() { const el = document.getElementById("trend-table"); if (!el) return; const now = new Date(); const months = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push(d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0")); } let html = ``; months.forEach(ym => { const os = state.orders.filter(o => String(o.createdAt || "").slice(0, 7) === ym && o.status !== "rejected"); const recv = os.filter(o => o.status === "received").length; const amt = os.reduce((s, o) => s + (Number(o.amount) || 0), 0); html += ``; }); html += "
注文 入荷金額
${ym.slice(2).replace("-", "/")} ${os.length || "-"} ${recv || "-"} ${amt ? "¥" + amt.toLocaleString() : "-"}
"; el.innerHTML = html; } function renderSummary() { try { renderShime(); } catch (e) {} try { renderAutoHideSetting_(); } catch (e) {} renderZan_(); renderTrend_(); const tEl = document.getElementById("sum-title"); const el = document.getElementById("sum-table"); if (!tEl || !el) return; const ym = sumY + "-" + String(sumM + 1).padStart(2, "0"); tEl.textContent = t("summary") + " " + sumY + "/" + (sumM + 1); const monthOrders = state.orders.filter(o => String(o.createdAt || "").slice(0, 7) === ym && o.status !== "rejected"); if (!monthOrders.length) { el.innerHTML = `
${t("no_orders")}
`; return; } const sup = {}; monthOrders.forEach(o => { const k = o.supplier || "-"; if (!sup[k]) sup[k] = { cnt: 0, recv: 0, slip: 0, amt: 0 }; sup[k].cnt++; if (o.status === "received") sup[k].recv++; if (o.slipUrl) sup[k].slip++; sup[k].amt += Number(o.amount) || 0; }); let html = ``; Object.entries(sup).sort((a, b) => b[1].cnt - a[1].cnt).forEach(([k, v]) => { html += ``; }); html += "
${t("sum_sup")} ${t("sum_cnt")} ${t("sum_recv")} ${t("sum_slip")} ${t("sum_amt")}
${escapeHtml(k.slice(0, 14))} ${v.cnt} ${v.recv} ${v.slip ? "📄" + v.slip : "-"} ${v.amt ? "¥" + v.amt.toLocaleString() : "-"}
"; el.innerHTML = html; } // v1.5: コンテキサー(発注管理ソフト)取込用CSV // 「発注伝票_入荷登録」エクスポート(2026-06-12実データで確認)の27列に完全一致+補助3列。 // 状態区分: 0=発注済(未入荷) / 2=入荷済(コンテキサー実データ428件の規約・1は存在しない)。発注残=入荷数量−発注数量。消費税率10% /* v8.1: プレビューとCSVは同じ関数から作る(単一ソース)=画面で見た中身とダウンロードされる中身が必ず一致。 社長「見れないまま取り込みボタンを押すのが怖い」→ 押すとまずプレビューを見せ、確認してからダウンロード */ function ctxBuildRows_() { const headers = [ "発注伝票ID", "元発注伝票ID", "状態区分", "発注登録区分", "発注日", "入荷予定日", "入荷日", "品目名", "仕入先名称", "発注数量", "入荷数量", "発注残", "キャンセル数", "単価", "単位1", "入数1", "単位2", "入数2", "値引き", "金額", "消費税率", "消費税", "発注備考1", "発注備考2", "発注備考3", "入力担当者", "タイムスタンプ", // 補助列(コンテキサーは取込時に自由にマッピング可能・不要なら無視) "品目コード", "仕入先コード", "希望納入日", "倉庫コード", "倉庫名" ]; const fmtD = (s) => s ? jdate_(s).replace(/-/g, "/") : ""; // 🐞v7.4: 日時→JST日付(UTC切り出しの前日ズレ防止) // 発注済み・入荷済みのみ(=実際に発注したもの) const targets = state.orders.filter(o => o.status === "ordered" || o.status === "received"); const rows = targets.map(o => { const recv = o.status === "received"; const qty = Number(o.qty) || 0; const price = o.amount && qty ? Math.round((Number(o.amount) / qty) * 100) / 100 : ""; const amount = Number(o.amount) || 0; return [ "", "", // 発注伝票ID・元発注伝票ID(コンテキサー側で採番) recv ? 2 : 0, "", // 状態区分=コンテキサー実データの規約(未入荷=0/入荷済=2。実物428件に「1」は存在しない)・発注登録区分 fmtD(o.orderedAt || o.createdAt), // 発注日 fmtD(o.dueDate), // 入荷予定日 recv ? fmtD(o.receivedAt) : "", // 入荷日 o.name, o.supplier || "", // 品目名・仕入先名称 qty, // 発注数量 recv ? qty : "", // 入荷数量 (recv ? qty : 0) - qty, // 発注残(入荷−発注) "", // キャンセル数 price, // 単価 "", 1, "", "", "", // 単位1・入数1・単位2・入数2・値引き amount || "", // 金額 10, // 消費税率 amount ? Math.round(amount * 0.1) : "", // 消費税 o.note || "", // 発注備考1 o.dueNote || "", // 発注備考2 o.poNo || "", // 発注備考3(アプリの発注書番号) o.byName, // 入力担当者 (o.createdAt || "").replace("T", " ").slice(0, 19), // タイムスタンプ o.code, o.supCode || "", (o.wishDate === "ASAP" ? "最短" : fmtD(o.wishDate)), "V0001", "本社倉庫" // 補助列 ]; }); return { headers: headers, rows: rows, targets: targets }; } /* 見た中身=落ちる中身の保証:20秒ポーリングが state.orders を丸ごと置換するため、 プレビューを開いた時点のスナップショットを保持し、ダウンロードは必ずそこから作る(モーダルを閉じたら破棄) */ var _ctxSnap = null; function openCtxPreview() { const b = ctxBuildRows_(); if (!b.rows.length) { showToast(t("no_orders")); return; } _ctxSnap = b; const nOrd = b.targets.filter(o => o.status === "ordered").length; const nRec = b.targets.filter(o => o.status === "received").length; const sum = b.targets.reduce((s, o) => s + (Number(o.amount) || 0), 0); document.getElementById("ctx-summary").innerHTML = '' + t("ctx_count") + ':' + b.rows.length + '(' + t("st_ordered") + ' ' + nOrd + '・' + t("st_received") + ' ' + nRec + ')|' + t("ctx_sum") + ':¥' + sum.toLocaleString() + '
' + t("ctx_legend") + '
'; const thead = '' + b.headers.map(h => '' + escapeHtml(h) + '').join('') + ''; const trs = b.rows.map(r => '' + r.map(c => '' + escapeHtml(String(c == null ? '' : c)) + '').join('') + '').join(''); document.getElementById("ctx-table-wrap").innerHTML = '' + thead + '' + trs + '
'; document.getElementById("ctx-modal").classList.add("active"); } function exportContexerCSV() { // モーダル表示中はプレビューのスナップショットから生成=画面で確認した中身と1行も違わないCSVを保証 const m = document.getElementById("ctx-modal"); const b = (_ctxSnap && m && m.classList.contains("active")) ? _ctxSnap : ctxBuildRows_(); if (!b.rows.length) { showToast(t("no_orders")); return; } const csv = "" + [b.headers, ...b.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 = "コンテキサー取込_発注データ_" + jstDateStr() + ".csv"; a.click(); showToast('📥 ' + t("ctx_dl_done")); } /* ===== 📦 段ボール棚卸・ロス報告(v8.2)=コンテキサー連動前提 ===== ・棚卸:数えた品目だけ入力→POSTでまとめて保存(URL長制限を受けない)→「棚卸データ登録」用CSV(プレビュー付き) ・ロス:破れ・濡れ等をその場で記録。写真=原本必須(4点セット標準)→Driveへ別POST→「調整データ登録」用CSV ・CSVの列はコンテキサー実物「ダンボール棚卸繰越処理用」に合わせた暫定=初回は1行テスト取込で要確認 */ function imgZoom_(src){ if(!src)return; var o=document.getElementById('img-zoom-ov'); if(!o){ o=document.createElement('div'); o.id='img-zoom-ov'; o.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.86);z-index:100000;display:none;align-items:center;justify-content:center;padding:14px;cursor:zoom-out;'; o.addEventListener('click',function(){ o.style.display='none'; o.innerHTML=''; }); document.body.appendChild(o); } o.innerHTML='' +'
×
'; o.style.display='flex'; } async function gasPost_(bodyObj){ var res=await fetch(getGasUrl_(),{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'}, body:JSON.stringify(Object.assign({token:ORDER_GAS_TOKEN},bodyObj))}); return await res.json(); } var _tanaVals={}, _tanaCat='__DNB__', _lossPhoto='', _lossReason=''; function tanaMasters_(){ return (state.masters||[]).filter(function(m){ return String(m.active).toLowerCase()!=='false'; }); } function tanaPrevMap_(){ var mp={}; (state.tanaoroshi||[]).forEach(function(r){ if(r.code&&!(r.code in mp)) mp[r.code]={counted:r.counted,date:r.date}; }); return mp; } function renderTana_(){ var d=document.getElementById('tana-date'); if(d&&!d.value) d.value=jstDateStr(); // 区分チップ(既定=「ダンボール」を含む区分。無ければ全部) var cats=[]; tanaMasters_().forEach(function(m){ var c=(m.cat||'').trim(); if(c&&cats.indexOf(c)<0)cats.push(c); }); var hasDnb=cats.some(function(c){return c.indexOf('ダンボール')>=0||c.indexOf('段ボール')>=0;}); if(_tanaCat==='__DNB__') _tanaCat=hasDnb?'DNB':'ALL'; var chips=[{k:'ALL',l:t('tana_all')}].concat(hasDnb?[{k:'DNB',l:'📦 '+t('tana_dnb')}]:[]).concat(cats.map(function(c){return {k:'C:'+c,l:c};})); document.getElementById('tana-cats').innerHTML=chips.map(function(ch){ return ''; }).join(''); renderTanaList_(); renderLossReasons_(); renderLossList_(); renderLossDatalist_(); } function tanaFiltered_(){ var q=(document.getElementById('tana-q').value||'').trim().toLowerCase(); return tanaMasters_().filter(function(m){ if(_tanaCat==='DNB'){ var c=m.cat||''; if(c.indexOf('ダンボール')<0&&c.indexOf('段ボール')<0)return false; } else if(_tanaCat!=='ALL'&&_tanaCat.indexOf('C:')===0){ if((m.cat||'')!==_tanaCat.slice(2))return false; } if(!q)return true; return ((m.name||'')+(m.code||'')+(m.supplier||'')).toLowerCase().indexOf(q)>=0; }); } function renderTanaList_(){ var list=tanaFiltered_(); var pm=tanaPrevMap_(); var el=document.getElementById('tana-list'); if(!el)return; el.innerHTML=list.slice(0,500).map(function(m){ var pv=pm[m.code]; var v=(_tanaVals[m.code]!=null)?_tanaVals[m.code]:''; return '
' +'
'+escapeHtml(m.name||'')+'
' +'
'+escapeHtml(m.code||'')+'|'+escapeHtml(m.supplier||'')+(pv?('|'+t('tana_prev')+' '+escapeHtml(String(pv.counted))+' ('+escapeHtml(String(pv.date||'').slice(5))+')'):'')+'
' +'' +'
'; }).join('')||('
'); tanaCount_(); } function tanaInput_(code,v){ if(v==='')delete _tanaVals[code]; else _tanaVals[code]=v; tanaCount_(); } function tanaCount_(){ var n=Object.keys(_tanaVals).length; var el=document.getElementById('tana-cnt'); if(el)el.textContent=n?('('+n+t('tana_items')+')'):''; } async function tanaSave_(){ var codes=Object.keys(_tanaVals); if(!codes.length){ showToast(t('tana_none')); return; } var date=document.getElementById('tana-date').value||jstDateStr(); var byName=(state.user&&state.user.name)||''; var mm={}; tanaMasters_().forEach(function(m){ mm[m.code]=m; }); var rows=codes.map(function(c){ var m=mm[c]||{}; return { date:date, code:c, name:m.name||'', supplier:m.supplier||'', counted:Number(_tanaVals[c])||0, unit:m.unit||'', price:(m.price!=null&&m.price!=='')?Number(m.price):'', note:'', byName:byName }; }); try{ if(window.showBusy)window.showBusy('💾 '+t('tana_saving')); var d=await gasPost_({action:'addTana',rows:rows}); if(!(d&&d.ok))throw new Error((d&&d.error)||'NG'); var now=new Date().toISOString(); state.tanaoroshi=rows.map(function(r){return Object.assign({id:'tn_'+Date.now(),createdAt:now},r);}).concat(state.tanaoroshi||[]); _tanaVals={}; renderTanaList_(); showToast('✅ '+t('tana_saved').replace('{n}',String(d.added||rows.length))); }catch(e){ showToast('⚠ '+t('tana_fail')+':'+(e.message||e)); } finally{ if(window.hideBusy)window.hideBusy(); } } /* ── 棚卸CSV(プレビュー→ダウンロード)=ダンボール棚卸繰越処理用の列に合わせた暫定 ── */ var _tanaPrevSnap=null; function tanaCsvPreview_(){ var date=document.getElementById('tana-date').value||jstDateStr(); var rows=(state.tanaoroshi||[]).filter(function(r){return String(r.date||'').slice(0,10)===date;}); // 同じ品目が同日に複数あれば最新(createdAt)だけ var seen={}; rows=rows.filter(function(r){ if(seen[r.code])return false; seen[r.code]=1; return true; }); if(!rows.length){ showToast(t('tana_csv_none')); return; } var headers=['追番','品目コード','品目名','仕入先名','棚卸日付_前回','入荷数_合計','棚卸在庫_前回','投入数_合計','調整数_合計','棚卸日付_今回','棚卸在庫_今回','仕入単価','棚卸在庫金額']; var dj=date.replace(/-/g,'/'); var body=rows.map(function(r,i){ var pr=(r.price!==''&&r.price!=null)?Number(r.price):''; var amt=(pr!=='')?Math.round(Number(r.counted)*pr):''; return [i+1, r.code||'', r.name||'', r.supplier||'', '', '', '', '', '', dj, Number(r.counted)||0, pr, amt]; }); _tanaPrevSnap={ headers:headers, rows:body, fname:'棚卸データ_'+date+'.csv' }; tanaShowPrev_('📤 '+t('tana_prev_t_tana')+'('+dj+')', t('tana_csv_note'), rows.length, null); } /* ── 調整CSV(ロス→プレビュー→ダウンロード)=調整データ登録用の暫定(実物サンプル待ち) ── */ function lossCsvPreview_(){ var rows=(state.losses||[]); if(!rows.length){ showToast(t('loss_none')); return; } var headers=['日付','品目コード','品目名','仕入先名','調整数','理由','担当者','備考','写真URL']; var body=rows.map(function(r){ return [String(r.date||'').slice(0,10).replace(/-/g,'/'), r.code||'', r.name||'', r.supplier||'', -(Math.abs(Number(r.qty)||0)), lossReasonLabel_(r.reason), r.byName||'', r.note||'', r.photoUrl||'']; }); _tanaPrevSnap={ headers:headers, rows:body, fname:'調整データ_ロス_'+jstDateStr()+'.csv' }; tanaShowPrev_('📤 '+t('tana_prev_t_loss'), t('loss_csv_note'), rows.length, null); } function tanaShowPrev_(title, note, count, sum){ document.getElementById('tana-prev-title').textContent=title; document.getElementById('tana-prev-summary').innerHTML=''+t('ctx_count')+':'+count+'
'+escapeHtml(note)+'
'; var b=_tanaPrevSnap; var thead=''+b.headers.map(function(h){return ''+escapeHtml(h)+'';}).join('')+''; var trs=b.rows.map(function(r){return ''+r.map(function(c){return ''+escapeHtml(String(c==null?'':c))+'';}).join('')+'';}).join(''); document.getElementById('tana-prev-wrap').innerHTML=''+thead+''+trs+'
'; document.getElementById('tana-prev-modal').classList.add('active'); } function tanaPrevDl_(){ var b=_tanaPrevSnap; if(!b)return; var csv=""+[b.headers].concat(b.rows).map(function(r){return r.map(function(c){return '"'+String(c==null?'':c).replace(/"/g,'""')+'"';}).join(',');}).join('\n'); var blob=new Blob([csv],{type:'text/csv'}); var a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=b.fname; a.click(); showToast('📥 '+t('ctx_dl_done')); } /* ── ロス報告 ── */ var LOSS_REASONS=[['break','loss_r_break'],['wet','loss_r_wet'],['dirt','loss_r_dirt'],['other','loss_r_other']]; function lossReasonLabel_(k){ var hit=LOSS_REASONS.filter(function(x){return x[0]===k;})[0]; var ja={break:'破れ',wet:'濡れ',dirt:'汚れ',other:'その他'}; return ja[k]||k||''; } function renderLossReasons_(){ var el=document.getElementById('loss-reasons'); if(!el)return; el.innerHTML=LOSS_REASONS.map(function(x){ return ''; }).join(''); } function renderLossDatalist_(){ var dl=document.getElementById('loss-item-list'); if(!dl)return; dl.innerHTML=tanaMasters_().map(function(m){return '';}).join(''); } function lossOnPhoto_(input){ var f=(input.files||[])[0]; input.value=''; if(!f)return; var r=new FileReader(); r.onload=async function(e){ var d=await compressDataUrl_(e.target.result,1400,0.72); _lossPhoto=d; var p=document.getElementById('loss-photo-prev'); if(p)p.innerHTML='
📎 '+t('loss_photo_ok')+'
'; }; r.readAsDataURL(f); } async function lossSave_(){ var name=(document.getElementById('loss-item').value||'').trim(); var m=tanaMasters_().filter(function(x){return (x.name||'')===name;})[0] ||tanaMasters_().filter(function(x){return name&&(x.name||'').indexOf(name)>=0;})[0]; if(!m){ showToast(t('loss_need_item')); return; } var qty=Number(document.getElementById('loss-qty').value)||0; if(qty<=0){ showToast(t('loss_need_qty')); return; } if(!_lossReason){ showToast(t('loss_need_reason')); return; } if(!_lossPhoto){ showToast(t('loss_photo_need')); return; } try{ if(window.showBusy)window.showBusy('💾 '+t('tana_saving')); var up=await gasPost_({action:'lossPhoto',photoBase64:_lossPhoto,itemName:m.name||'',reason:lossReasonLabel_(_lossReason)}); if(!(up&&up.ok&&up.url))throw new Error((up&&up.error)||'photo NG'); var rec={ date:jstDateStr(), code:m.code||'', name:m.name||'', supplier:m.supplier||'', qty:qty, reason:_lossReason, note:(document.getElementById('loss-note').value||'').trim(), photoUrl:up.url, byName:(state.user&&state.user.name)||'' }; var d=await gasPost_({action:'addLoss',rec:rec}); if(!(d&&d.ok))throw new Error((d&&d.error)||'NG'); state.losses=[Object.assign({id:d.id||('ls_'+Date.now()),createdAt:new Date().toISOString()},rec)].concat(state.losses||[]); _lossPhoto=''; _lossReason=''; ['loss-item','loss-qty','loss-note'].forEach(function(id){var e=document.getElementById(id);if(e)e.value='';}); var p=document.getElementById('loss-photo-prev'); if(p)p.innerHTML=''; renderLossReasons_(); renderLossList_(); showToast('✅ '+t('loss_saved')); }catch(e){ showToast('⚠ '+t('tana_fail')+':'+(e.message||e)); } finally{ if(window.hideBusy)window.hideBusy(); } } function renderLossList_(){ var el=document.getElementById('loss-list'); if(!el)return; var rows=(state.losses||[]).slice(0,30); el.innerHTML=rows.map(function(r){ var th=r.photoUrl?(''):''; return '
'+th +'
'+escapeHtml(r.name||'')+' −'+escapeHtml(String(Math.abs(Number(r.qty)||0)))+'
' +'
'+escapeHtml(String(r.date||'').slice(0,10))+'|'+t('loss_r_'+r.reason)+'|'+escapeHtml(r.byName||'')+(r.note?('|'+escapeHtml(r.note)):'')+'
'; }).join('')||('
'+t('loss_none')+'
'); } // タブ 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 === "status") { renderStatusChips(); renderOrders(); } if (tab === "order") { renderCats(); renderItems(); } if (tab === "cart") { renderCart_(); } if (tab === "calendar") renderCalendar(); if (tab === "tana") renderTana_(); if (tab === "settings") renderSummary(); if (tab === "ai") renderAiChat(); if (tab === "history") renderHistory(); if (tab === "sanpai") renderSanpai(); } function renderHistory(){ /* 入力待ち */ } async function searchHistory(){ const q=(document.getElementById('hist-q').value||'').trim(); const from=document.getElementById('hist-from').value||''; const to=document.getElementById('hist-to').value||''; const st=document.getElementById('hist-status'); const res=document.getElementById('hist-results'); if(!q && !from && !to){ st.textContent='品目名・仕入先などを入れて検索してください'; return; } const url=getGasUrl_(); if(!url){ st.textContent='GAS未設定(設定タブでURLを入れてください)'; return; } st.textContent='検索中…'; res.innerHTML=''; try{ const d=await jsonpFetch_(url,{action:'searchHistory',q:q,from:from,to:to}); if(!d||!d.ok){ st.textContent='エラー:'+((d&&d.error)||'失敗'); return; } st.textContent='該当 '+d.total+'件'+(d.shown'+escapeHtml(r.src)+''):''; return '
' +'
'+escapeHtml(r.name||'')+srcB+'
' +'
'+escapeHtml(r.spec||'')+' / '+escapeHtml(r.supplier||'')+'
' +'
' +'📅 '+escapeHtml(r.date||'')+'' +'数量 '+escapeHtml(String(r.qty==null?'':r.qty))+escapeHtml(r.unit||'')+'' +'単価 '+escapeHtml(String(r.price==null?'':r.price))+'円' +'金額 '+escapeHtml(String(r.amount==null?'':r.amount))+'円' +(r.due?'入荷 '+escapeHtml(r.due)+'':'') +'
'; }).join(''); }catch(e){ st.textContent='通信エラー。もう一度お試しください。'; } } // ============================================ // ♻️ 産廃回収依頼タブ(株式会社エコロジス宛FAX用紙・専用GAS/全部 sp 接頭辞で既存と無干渉) // ※ デプロイ後に SANPAI_GAS_URL を社長のGAS URLへ差し替える(私が焼く) // ============================================ const SANPAI_GAS_URL = 'https://script.google.com/macros/s/AKfycby6VRux6IQeyCZVepmjwC8kMc6LCViHpaoFg7a5rJsvJD4GVnunMnzv-kMoWoz0zx6s/exec'; const SANPAI_TOKEN = 'kowa-sanpai-3e8b1d'; function spGasReady_(){ return SANPAI_GAS_URL && SANPAI_GAS_URL.indexOf('__') !== 0; } // 産廃GASへ POST(text/plain=プリフライト回避・token付き)。未設定なら null を返す。 async function spGas(action, payload){ if(!spGasReady_()) return null; const body = Object.assign({ token: SANPAI_TOKEN, action: action }, payload || {}); const res = await fetch(SANPAI_GAS_URL, { method:'POST', headers:{ 'Content-Type':'text/plain;charset=utf-8' }, body: JSON.stringify(body) }); return await res.json(); } // 選択肢クリック=赤丸(data-group は単一排他/data-name は複数トグル) function pick(el){ const g = el.getAttribute('data-group'); if(g){ document.querySelectorAll('.sp-opt[data-group="'+g+'"]').forEach(function(o){ if(o!==el) o.classList.remove('on'); }); } el.classList.toggle('on'); } // 今日(令和)を自動セット function spToday(){ const n = jstNow(); const rw = n.getUTCFullYear() - 2018; // 2019=令和1(JST基準) const y=document.getElementById('sp-d-y'), m=document.getElementById('sp-d-m'), d=document.getElementById('sp-d-d'); if(y) y.value = rw; if(m) m.value = (n.getUTCMonth()+1); if(d) d.value = n.getUTCDate(); return rw; } // data-group 単一選択の選ばれた値を取得 function spPicked_(group){ const el = document.querySelector('.sp-opt[data-group="'+group+'"].on'); return el ? el.textContent.trim() : ''; } // data-name 複数選択(回収品目)を「・」で連結 function spPickedMulti_(name){ return Array.prototype.slice.call(document.querySelectorAll('.sp-opt[data-name="'+name+'"].on')) .map(function(el){ return el.textContent.trim(); }).join('・'); } function spVal_(id){ const el=document.getElementById(id); return el ? (el.value||'').trim() : ''; } // フォーム→保存 async function saveSanpai(){ const hinmoku = spPickedMulti_('hinmoku'); const wish1 = spVal_('sp-w1m') ? (spVal_('sp-w1m')+'月'+spVal_('sp-w1d')+'日('+spVal_('sp-w1y')+')') : ''; const wish2 = spVal_('sp-w2m') ? (spVal_('sp-w2m')+'月'+spVal_('sp-w2d')+'日('+spVal_('sp-w2y')+')') : ''; const wish3 = spVal_('sp-w3m') ? (spVal_('sp-w3m')+'月'+spVal_('sp-w3d')+'日('+spVal_('sp-w3y')+')') : ''; const wish4 = spVal_('sp-w4m') ? (spVal_('sp-w4m')+'月'+spVal_('sp-w4d')+'日('+spVal_('sp-w4y')+')') : ''; const ryo = spVal_('sp-ryo'); const basho = spVal_('sp-basho'); const note = spVal_('sp-note'); const tantou = spVal_('sp-tantou'); // ガード:品目も量も希望日も全部空なら保存しない if(!hinmoku && !ryo && !wish1 && !note){ showToast('品目や回収量などを入力してください'); return; } if(!hinmoku){ showToast('回収品目を選んでください'); return; } if(!spGasReady_()){ showToast('GAS未設定(記録は保存できません)'); return; } const reqDate = '令和'+spVal_('sp-d-y')+'年'+spVal_('sp-d-m')+'月'+spVal_('sp-d-d')+'日'; const row = { reqDate: reqDate, shobun: spPicked_('shobun'), hinmoku: hinmoku, hinmokuOther: spVal_('sp-hinmoku-other'), ryo: ryo, sharyo: spPicked_('sharyo'), box: spPicked_('box'), basho: basho, wish1: wish1, wish2: wish2, wish3: wish3, wish4: wish4, doshuku: spPicked_('doshuku'), note: note, byName: (state.user ? state.user.name : tantou) || tantou || '', byId: state.user ? (state.user.id || '') : '', createdAt: new Date().toISOString() }; showToast('記録中…'); try{ const r = await spGas('add', { table:'sanpai', row: row }); if(r && r.ok){ showToast('✅ 記録しました'); loadSanpaiHistory(); } else { showToast('記録に失敗:'+((r&&r.error)||'不明')); } }catch(e){ showToast('通信エラー。もう一度お試しください。'); } } // 履歴読み込み async function loadSanpaiHistory(){ const box = document.getElementById('sp-history'); if(!box) return; if(!spGasReady_()){ box.innerHTML = '
(GAS未設定のため履歴は表示できません)
'; return; } box.textContent = '読み込み中…'; try{ const rows = await spGas('get', { table:'sanpai' }); if(!Array.isArray(rows)){ box.innerHTML = '
読み込みエラー
'; return; } const list = rows.filter(function(r){ return String(r.deleted).toUpperCase() !== 'TRUE'; }) .sort(function(a,b){ return (Number(b.id)||0) - (Number(a.id)||0); }); if(!list.length){ box.innerHTML = '
まだ依頼の記録はありません
'; return; } box.innerHTML = list.map(function(r){ const wish = [r.wish1,r.wish2,r.wish3,r.wish4].filter(Boolean).join(' / '); return '
' + '
'+escapeHtml(r.reqDate||'')+' '+escapeHtml(r.shobun||'')+'
' + '
品目:'+escapeHtml(r.hinmoku||'')+(r.hinmokuOther?'('+escapeHtml(r.hinmokuOther)+')':'')+' / 量:'+escapeHtml(r.ryo||'')+'
' + (wish ? '
希望日:'+escapeHtml(wish)+'
' : '') + '
記録者:'+escapeHtml(r.byName||'')+'
' + '' + '
'; }).join(''); }catch(e){ box.innerHTML = '
通信エラー。もう一度お試しください。
'; } } // 履歴の1件をフォームへ復元(再依頼) function spRedo(json){ let r; try{ r = JSON.parse(json); }catch(e){ return; } // 単一選択の復元 function setGroup(group, val){ document.querySelectorAll('.sp-opt[data-group="'+group+'"]').forEach(function(o){ o.classList.toggle('on', o.textContent.trim() === val); }); } setGroup('shobun', r.shobun||''); setGroup('sharyo', r.sharyo||''); setGroup('box', r.box||''); setGroup('doshuku', r.doshuku||''); // 複数選択(品目) const sel = (r.hinmoku||'').split('・'); document.querySelectorAll('.sp-opt[data-name="hinmoku"]').forEach(function(o){ o.classList.toggle('on', sel.indexOf(o.textContent.trim()) >= 0); }); function setVal(id, v){ const el=document.getElementById(id); if(el) el.value = v||''; } setVal('sp-hinmoku-other', r.hinmokuOther); setVal('sp-ryo', r.ryo); setVal('sp-basho', r.basho); setVal('sp-note', r.note); spToday(); // 日付は今日に showToast('フォームに復元しました(日付は今日になっています)'); window.scrollTo({ top:0, behavior:'smooth' }); } // クリア function clearSanpai(){ if(!confirm('入力をすべて消しますか?(記録済みの履歴は消えません)')) return; document.querySelectorAll('#sp-sheet .sp-in, #sp-sheet .sp-ta').forEach(function(el){ el.value=''; }); document.querySelectorAll('#sp-sheet .sp-opt.on').forEach(function(el){ el.classList.remove('on'); }); spToday(); } // 産廃フォームだけ印刷 function printSanpai(){ document.body.classList.add('printing-sanpai'); function done(){ document.body.classList.remove('printing-sanpai'); window.removeEventListener('afterprint', done); } window.addEventListener('afterprint', done); setTimeout(function(){ try{ window.print(); }catch(e){ done(); } }, 30); // afterprint が来ない環境向けの保険 setTimeout(done, 4000); } // タブ初期化 function renderSanpai(){ spToday(); loadSanpaiHistory(); } // ============================================ // 🤖 AI相談(欠品させない・無駄に頼まない 発注の相談役・材料マスタ/注文履歴引き当て・👍👎・多言語) // AIの窓口は設備保全アプリのGAS(応答が読める・キー&AI方針設定済み)を共用。app='材料注文'で発注用ルールが適用される // ============================================ const ORDER_AI_GAS = 'https://script.google.com/macros/s/AKfycbzJiwPtNGERWdn4-2ehL7K-Splz-6edlr6SnSva7ZgWRP9ZCFQSkuCLxvRXvrZCAjeT7g/exec'; const ORDER_AI_TOKEN = 'kowa-setsubi-2b9f4c7a1e8d63a05f0719'; let orderAIHistory = []; let orderAIGreeted = false; const ORDER_AI_UI = { title:{ja:'🤖 AI相談(発注)',en:'🤖 AI (Ordering)',vi:'🤖 AI Đặt hàng',si:'🤖 AI ඇණවුම්',id:'🤖 AI Pemesanan',fil:'🤖 AI Pag-order',zh:'🤖 AI订货'}, desc:{ja:'材料・在庫・発注のことを相談できます。マスタと注文履歴から事実ベースで答えます。',en:'Ask about materials, stock and ordering. I answer from the master list and order history.',vi:'Hỏi về vật tư, tồn kho và đặt hàng. Tôi trả lời từ danh mục và lịch sử đặt hàng.',si:'ද්‍රව්‍ය/තොග/ඇණවුම් ගැන අසන්න. මාස්ටර් සහ ඇණවුම් ඉතිහාසය අනුව පිළිතුරු දෙමි.',id:'Tanya soal material, stok, dan pemesanan. Saya jawab dari daftar master & riwayat pesanan.',fil:'Magtanong tungkol sa materyales, stock, at pag-order. Sasagot ako base sa master list at order history.',zh:'可咨询材料、库存、订货。根据物料主数据和订货历史,按事实回答。'}, placeholder:{ja:'例:ラミネートフィルムまだ在庫ある?',en:'e.g., do we still have laminate film?',vi:'VD: còn màng ép không?',si:'උදා: ලැමිනේට් චිත්‍රපට තවම තිබේද?',id:'mis: masih ada film laminasi?',fil:'hal., meron pa bang laminate film?',zh:'例:覆膜膜还有库存吗?'}, sending:{ja:'調べています…',en:'Checking…',vi:'Đang kiểm tra…',si:'පරීක්ෂා කරමින්…',id:'Memeriksa…',fil:'Sinusuri…',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 orderAiUI(k){ const d=ORDER_AI_UI[k]||{}; return d[(state&&state.language)||'ja']||d.ja; } function orderLangName(){ return ({ja:'日本語',en:'English',vi:'Vietnamese',si:'Sinhala',id:'Indonesian',fil:'Filipino',zh:'Chinese'})[(state&&state.language)||'ja']||'日本語'; } function renderAiChat(){ const ti=document.getElementById('order-ai-title'); if(ti) ti.textContent=orderAiUI('title'); const de=document.getElementById('order-ai-desc'); if(de) de.textContent=orderAiUI('desc'); const inp=document.getElementById('order-ai-input'); if(inp) inp.placeholder=orderAiUI('placeholder'); if(!orderAIGreeted){ orderAIGreeted=true; orderAIAppend('ai', orderAiUI('desc')); } } // 材料マスタ+注文履歴から質問に近いものを引き当て(bigram重なり。発注点割れ・直近注文を優遇=事実ベース回答の根拠に) function orderAIRelevant(q){ const bg=function(s){ s=String(s||'').replace(/\s/g,'').toLowerCase(); var a=[]; for(var i=0;i=0)s++; }); return s; }; // ── マスタ(在庫・発注点を含む) ── var masters=(state.masters||[]).filter(function(m){ return m && m.active!==false && m.name; }); var ms=masters.map(function(m){ var s=overlap((m.name||'')+' '+(m.code||'')+' '+(m.spec||'')+' '+(m.cat||'')+' '+(m.supplier||'')); var rp=Number(m.reorderPoint)||0, st=Number(m.stock)||0; if(rp>0 && st<=rp) s+=2; // 発注点割れは優遇 return {m:m,s:s}; }); ms.sort(function(a,b){ return b.s-a.s; }); var mTop=ms.filter(function(x){ return x.s>0; }).slice(0,12).map(function(x){ return x.m; }); // ── 注文履歴(前回いつ頼んだ 等) ── var orders=(state.orders||[]); var os=orders.map(function(o){ var s=overlap((o.name||'')+' '+(o.code||'')+' '+(o.spec||'')+' '+(o.supplier||'')+' '+(o.note||'')); return {o:o,s:s}; }); os.sort(function(a,b){ return (b.s-a.s) || String(b.o.createdAt||'').localeCompare(String(a.o.createdAt||'')); }); var oTop=os.filter(function(x){ return x.s>0; }).slice(0,12).map(function(x){ return x.o; }); if(!oTop.length) oTop=orders.slice(0,6); // 引き当て無しでも直近を少し return { masters:mTop, orders:oTop }; } function orderAICasesText(q){ var rel=orderAIRelevant(q); var lines=[]; lines.push('■ 材料マスタ(在庫・発注点・単価・仕入先。質問に近いもの)'); if(rel.masters.length){ rel.masters.forEach(function(m){ var rp=Number(m.reorderPoint)||0, st=Number(m.stock)||0; var low=(rp>0 && st<=rp)?'(⚠発注点割れ)':''; lines.push('・'+(m.name||'-')+'|コード:'+(m.code||'-')+(m.spec?'|'+m.spec:'')+'|在庫:'+(m.stock!=null&&m.stock!==''?st:'不明')+(rp>0?'/発注点:'+rp:'')+low+(m.lot?'|最低ロット:'+m.lot:'')+(m.unit?'|単位:'+m.unit:'')+(m.price?'|単価:¥'+m.price:'')+(m.supplier?'|仕入先:'+m.supplier:'')); }); } else { lines.push('(一致する材料が見つかりません)'); } lines.push(''); lines.push('■ 注文履歴(前回いつ・誰が・いくつ頼んだか。新しい順)'); if(rel.orders.length){ rel.orders.forEach(function(o){ lines.push('・'+fmtDate_(o.createdAt)+'|'+(o.name||'-')+'|数量:'+(o.qty!=null?o.qty:'-')+(o.unit?o.unit:'')+'|状態:'+orderStatusJa_(o.status)+(o.byName?'|依頼:'+o.byName:'')+(o.supplier?'|仕入先:'+o.supplier:'')); }); } else { lines.push('(該当する注文履歴がありません)'); } return lines.join('\n'); } // 履歴用の簡易日付(fmtDate_ 等が無い環境でも安全に動くフォールバック) function fmtDate_(iso){ if(!iso) return '-'; try{ var d=new Date(iso); if(isNaN(d.getTime())) return String(iso).slice(0,10); var p=function(x){return String(x).padStart(2,'0');}; return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate()); }catch(e){ return String(iso).slice(0,10); } } function orderStatusJa_(s){ return ({pending:'承認待ち',approved:'承認済',rejected:'却下',ordered:'発注済',received:'入荷済',cancelled:'取消'})[s]||(s||'-'); } function orderAISystem(q){ return [ 'あなたは興和化学(プラスチック加工)の「発注の相談役」AIです。返答は必ず '+orderLangName()+' で、短く・具体的に・現場目線で。', '大義:欠品させない・無駄に頼まない。発注すべきかどうかの判断を、事実(在庫・発注点・前回いつ頼んだか)で支える。', '回答は必ず下の「自社の在庫/発注データ」を根拠にする。「前回いつ頼んだ」「発注点を下回っている」等はデータから事実として答える。', 'データに無いこと・数値が不明なことは推測しない。その場合は '+orderLangName()+' で「担当(事務)に確認してね」と返す。', '「これで足りる」「絶対に欠品しない」等の断定はしない。あくまで"判断材料"を出し、最終判断・発注は人に委ねる。', '発注が必要そうなときは、最後に「📦 アプリの『注文する』から発注してね」と促す(報連相)。', '', '【自社の在庫/発注データ(質問に近いもの・参考)】', orderAICasesText(q), '', '材料・在庫・発注と関係ない私的な雑談には答えず、'+orderLangName()+'で「ごめんね、材料・発注のことだけ答えられます」と短く返す。' ].join('\n'); } async function orderAICall(system, messages){ const res=await fetch(ORDER_AI_GAS,{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'}, body:JSON.stringify({action:'ai',token:ORDER_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:orderAiUI('err'); } async function orderAISend(){ const inp=document.getElementById('order-ai-input'); const text=(inp?inp.value:'').trim(); if(!text) return; inp.value=''; orderAIAppend('me',text); orderAIAppend('ai','…'); const st=document.getElementById('order-ai-status'); if(st) st.textContent=orderAiUI('sending'); try{ const sys=orderAISystem(text); const msgs=orderAIHistory.concat([{role:'user',content:text}]).slice(-10); const reply=await orderAICall(sys,msgs); orderAIReplaceLast(reply); orderAIAddFeedback(text,reply); orderAIHistory.push({role:'user',content:text},{role:'assistant',content:reply}); if(orderAIHistory.length>16) orderAIHistory=orderAIHistory.slice(-16); }catch(e){ orderAIReplaceLast(orderAiUI('err')); } if(st) st.textContent=''; } function orderAIAppend(who,text){ const box=document.getElementById('order-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 orderAIReplaceLast(text){ const box=document.getElementById('order-ai-messages'); if(!box) return; const ai=box.querySelectorAll('[data-ai="1"]'); const last=ai[ai.length-1]; if(last) last.textContent=text; else orderAIAppend('ai',text); box.scrollTop=box.scrollHeight; } function orderAIAddFeedback(q,a){ const box=document.getElementById('order-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(){ orderAISendFeedback(q,a,'good',row); }; b.onclick=function(){ orderAISendFeedback(q,a,'bad',row); }; row.appendChild(g); row.appendChild(b); box.appendChild(row); box.scrollTop=box.scrollHeight; } async function orderAISendFeedback(q,a,verdict,row){ try{ await fetch(ORDER_AI_GAS,{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'}, body:JSON.stringify({action:'aifeedback',token:ORDER_AI_TOKEN,app:'材料注文',user:(state.user&&state.user.name)||'',question:q,answer:a,verdict:verdict})}); }catch(e){} if(row) row.innerHTML=''+(verdict==='good'?'👍 ありがとう!':'👎 教えてくれてありがとう(改善します)')+''; } // v5.5: 注文の状態変更だけの軽い再描画(材料カタログ338件を再描画しない=承認の反応を速く) function refreshOrdersView_() { renderStatusChips(); renderOrders(); updateSyncChip_(); } function renderAll() { renderCats(); renderItems(); renderStatusChips(); renderOrders(); updateSyncChip_(); updateCartUI_(); // v5.2: 買い物カゴFABを反映 // v1.2: 表示中ならカレンダー・集計も最新化 try { if (document.getElementById("section-calendar").classList.contains("active")) renderCalendar(); if (document.getElementById("section-settings").classList.contains("active")) { renderSummary(); renderPoHistory(); } renderPresence_(); renderLoginLog_(); } catch (e) {} } // ============================================ // v2.8 ログイン在席ランプ+ログイン履歴(全員に見える) // ============================================ function deviceId_() { let d = localStorage.getItem("kowa_order_device"); if (!d) { d = "d_" + Date.now().toString(36) + Math.random().toString(36).slice(2,6); localStorage.setItem("kowa_order_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 list = (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 }; }).sort((a, b) => (b.online - a.online) || (b.last - a.last)); const online = list.filter(x => x.online); if (!online.length) { el.innerHTML = '
いま誰もログインしていません
'; 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 = '
履歴はまだありません
'; return; } el.innerHTML = logs.slice(0, 40).map(g => { const d = g.at ? String(g.at).slice(0, 16).replace("T", " ").slice(5) : ""; return `
${escapeHtml(g.name || g.id)}${d}
`; }).join(""); } // ============================================ // v2.7 発注書の発行履歴(印刷/メールの記録) // ============================================ function renderPoHistory() { const el = document.getElementById("po-history"); if (!el) return; const groups = {}; (state.orders || []).filter(o => o.poNo).forEach(o => { if (!groups[o.poNo]) groups[o.poNo] = { poNo:o.poNo, sup:o.supplier||"-", at:o.poSentAt||o.orderedAt||"", by:o.orderedBy||"", method:o.poMethod||"", n:0 }; groups[o.poNo].n++; }); const list = Object.values(groups).sort((a,b)=>String(b.at).localeCompare(String(a.at))); if (!list.length) { el.innerHTML = '
発行履歴はまだありません
'; return; } el.innerHTML = list.slice(0,30).map(g => { const m = g.method === "email" ? "📧メール" : (g.method === "print" ? "🖨印刷" : "-"); const d = g.at ? String(g.at).slice(0,16).replace("T"," ").slice(5) : ""; return `
${escapeHtml(g.poNo)}|${escapeHtml(g.sup)}
${d}|${m}|${escapeHtml(g.by)}|${g.n}件
`; }).join(""); } function reprintPo(poNo) { const orders = (state.orders || []).filter(o => o.poNo === poNo); if (!orders.length) return; const o0 = orders[0]; openPoWindow_(poNo, o0.supplier || "-", orders, o0.wishDate || "", "", new Date(o0.orderedAt || Date.now())); } // ============================================ // 起動 // ============================================ function boot() { applyI18n(); renderLangGrids(); try{ applyOrderCalParam_(); }catch(e){} // 入口カレンダーの ?d= を読む(自動ログイン=enterApp非経由でも確実に発注予定日へ) // loginScreen は常に非表示(enterApp()経由でここに来る) const ls = document.getElementById('loginScreen'); if (ls) ls.style.display = 'none'; document.getElementById("app").style.display = "block"; const roleLabel = t("role_" + state.user.role); document.getElementById("who-line").textContent = state.user.name + "(" + (state.user.org ? state.user.org + "・" : "") + roleLabel + ")"; document.getElementById("me-name").textContent = state.user.name; document.getElementById("me-role").textContent = roleLabel; document.getElementById("ver-line").textContent = "v" + APP_VERSION; updateVersionBadge(); // me-ver 等 .app-version バッジを「v3.7 (日付) ✅最新」に document.getElementById("admin-tools").style.display = (state.user.role === "admin" || state.user.role === "president") ? "block" : "none"; document.getElementById("master-admin").style.display = (state.user.role === "admin" || state.user.role === "president") ? "block" : "none"; fillMasterCatOptions_(); renderLastImport_(); renderPoHistory(); setGasStatus_(); renderAll(); sendLoginEvent_(); // v2.8: ログイン記録 startPoll_(); } document.querySelectorAll(".tab-btn").forEach(b => { b.onclick = () => switchTab(b.dataset.tab); }); // ============================================ // 初期化(認証チェック → loginScreen or boot) // ============================================ (function init() { // localStorage から前回ログイン情報を復元 loadUser(); if (currentUser && currentUser.id && currentUser.name) { // 既ログイン済み → state.user に同期して直接 boot state.user = { id: currentUser.id, name: currentUser.name, role: currentUser.role || 'staff', org: currentUser.org || '' }; saveState(); boot(); } else { // 未ログイン → loginScreen を表示してメンバー読み込み const ls = document.getElementById('loginScreen'); if (ls) ls.style.display = 'flex'; document.getElementById('app').style.display = 'none'; initLogin(); } })();
処理中…
📖 マニュアルAI