お店の売上分析ツール「Mise」
【更新履歴】
・2026/2/20 バージョン1.0公開。
・2026/2/20 バージョン1.1公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MISE v2 — 小さなお店の販売分析</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Kaisei+Opti:wght@400;700&family=IBM+Plex+Sans+JP:wght@300;400;500;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg0:#12100e; --bg1:#1c1916; --bg2:#252119; --bg3:#2f2b22;
--border:#3a342a; --border2:#4a4238;
--text0:#f0e8d8; --text1:#c8b99a; --text2:#8a7a66; --text3:#5a4e40;
--amber:#e8a230; --amber-l:rgba(232,162,48,0.14); --amber-d:#b87d1a;
--green:#5cb85a; --green-l:rgba(92,184,90,0.12);
--red:#e05050; --red-l:rgba(224,80,80,0.12);
--blue:#4a9fd4; --blue-l:rgba(74,159,212,0.12);
--purple:#a07cd4;--purple-l:rgba(160,124,212,0.12);
--teal:#40b8a0; --teal-l:rgba(64,184,160,0.12);
--r:6px; --sh:0 4px 24px rgba(0,0,0,0.4);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
html{scroll-behavior:smooth;}
body{background:var(--bg0);color:var(--text0);font-family:'IBM Plex Sans JP',sans-serif;font-size:13px;line-height:1.6;min-height:100vh;overflow-x:hidden;}
::-webkit-scrollbar{width:5px;height:5px;}
::-webkit-scrollbar-track{background:var(--bg1);}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px;}
/* ── Header ── */
header{position:sticky;top:0;z-index:300;background:var(--bg1);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:16px;padding:0 20px;height:52px;}
.logo{font-family:'Kaisei Opti',serif;font-size:20px;letter-spacing:.12em;color:var(--amber);}
.logo-sub{font-size:10px;color:var(--text2);letter-spacing:.2em;text-transform:uppercase;border-left:1px solid var(--border);padding-left:14px;white-space:nowrap;}
.hdr-r{margin-left:auto;display:flex;align-items:center;gap:10px;}
.hkpi{display:flex;flex-direction:column;align-items:flex-end;padding:3px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);}
.hkpi-l{font-size:9px;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;}
.hkpi-v{font-family:'JetBrains Mono',monospace;font-size:14px;font-weight:600;color:var(--amber);}
/* ── Layout ── */
.layout{display:grid;grid-template-columns:272px 1fr;min-height:calc(100vh - 52px);}
.side{background:var(--bg1);border-right:1px solid var(--border);overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:12px;}
.ss{border-bottom:1px solid var(--border);padding-bottom:12px;}
.ss:last-child{border-bottom:none;}
.sl{font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--text3);margin-bottom:7px;font-family:'JetBrains Mono',monospace;}
/* forms */
.f{display:flex;flex-direction:column;gap:3px;margin-bottom:7px;}
.f label{font-size:11px;color:var(--text2);}
input[type=text],input[type=number],input[type=date],select,textarea{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:6px 9px;font-family:inherit;font-size:12px;color:var(--text0);outline:none;width:100%;transition:border-color .15s;}
input:focus,select:focus,textarea:focus{border-color:var(--amber);}
select option{background:var(--bg2);}
textarea{resize:vertical;min-height:50px;font-size:11px;}
.btn{display:flex;align-items:center;justify-content:center;gap:5px;padding:7px 12px;border-radius:var(--r);font-family:inherit;font-size:12px;font-weight:500;cursor:pointer;border:none;width:100%;transition:all .15s;white-space:nowrap;}
.btn-amber{background:var(--amber);color:#12100e;}
.btn-amber:hover{background:#f0b040;box-shadow:0 2px 10px rgba(232,162,48,.3);}
.btn-outline{background:transparent;color:var(--text1);border:1px solid var(--border);}
.btn-outline:hover{border-color:var(--border2);background:var(--bg2);}
.btn-green{background:var(--green);color:#12100e;}
.btn-green:hover{background:#6cd468;}
.btn-sm{padding:5px 9px;font-size:11px;width:auto;}
.btn:disabled{opacity:.4;cursor:not-allowed;}
.drop-zone{border:2px dashed var(--border2);border-radius:var(--r);padding:14px 10px;text-align:center;cursor:pointer;color:var(--text2);font-size:12px;background:var(--bg2);transition:all .2s;}
.drop-zone:hover,.drop-zone.drag{border-color:var(--amber);color:var(--amber);background:var(--amber-l);}
.drop-zone input{display:none;}
/* log */
#log{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2);background:var(--bg0);border:1px solid var(--border);border-radius:var(--r);padding:5px 7px;max-height:68px;overflow-y:auto;}
#log div{margin-bottom:1px;}
#log .ok{color:var(--green);}#log .err{color:var(--red);}#log .info{color:var(--blue);}#log .warn{color:var(--amber);}
/* campaign list */
.cp-list{display:flex;flex-direction:column;gap:3px;max-height:120px;overflow-y:auto;}
.cp-item{display:flex;align-items:center;gap:5px;padding:4px 7px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);font-size:11px;}
.cp-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;}
.cp-name{flex:1;color:var(--text1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.cp-dates{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text2);}
.cp-del{cursor:pointer;color:var(--text3);padding:0 2px;}.cp-del:hover{color:var(--red);}
/* ── Main ── */
.main{display:flex;flex-direction:column;overflow-y:auto;}
/* Banner */
.banner{background:linear-gradient(135deg,var(--bg2) 0%,var(--bg1) 100%);border-bottom:1px solid var(--border);padding:14px 22px;display:grid;grid-template-columns:auto 1fr repeat(3,auto);gap:14px;align-items:center;}
.banner.hidden{display:none;}
.s-ring{width:46px;height:46px;border-radius:50%;border:2px solid var(--border2);background:var(--bg2);display:flex;align-items:center;justify-content:center;font-size:22px;transition:all .4s;}
.s-ring.good{border-color:var(--green);background:var(--green-l);box-shadow:0 0 12px rgba(92,184,90,.2);}
.s-ring.warn{border-color:var(--amber);background:var(--amber-l);box-shadow:0 0 12px rgba(232,162,48,.2);}
.s-ring.bad {border-color:var(--red); background:var(--red-l); box-shadow:0 0 12px rgba(224,80,80,.2);}
.s-lbl{font-size:9px;color:var(--text2);letter-spacing:.1em;text-align:center;margin-top:2px;}
.b-msg{}
.b-hl{font-family:'Kaisei Opti',serif;font-size:15px;color:var(--text0);line-height:1.4;}
.b-dt{font-size:11px;color:var(--text2);margin-top:2px;}
.bkpi{display:flex;flex-direction:column;align-items:flex-end;padding:6px 12px;background:var(--bg0);border:1px solid var(--border);border-radius:var(--r);min-width:100px;}
.bkpi-l{font-size:9px;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;}
.bkpi-v{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:600;color:var(--text0);}
.bkpi-v.up{color:var(--green);}.bkpi-v.down{color:var(--red);}
.bkpi-s{font-size:10px;color:var(--text3);}
/* Tabs */
.tab-bar{display:flex;overflow-x:auto;background:var(--bg1);border-bottom:1px solid var(--border);padding:0 18px;position:sticky;top:52px;z-index:200;}
.tab-bar::-webkit-scrollbar{height:2px;}
.tab{padding:11px 14px;font-size:12px;cursor:pointer;color:var(--text2);border-bottom:2px solid transparent;white-space:nowrap;transition:all .15s;font-weight:500;display:flex;align-items:center;gap:5px;}
.tab:hover{color:var(--text1);}
.tab.active{color:var(--amber);border-bottom-color:var(--amber);}
.tbadge{background:var(--red);color:#fff;font-size:9px;padding:1px 5px;border-radius:8px;font-family:'JetBrains Mono',monospace;}
/* Content */
.tab-content{display:none;padding:18px 22px;}
.tab-content.active{display:block;}
/* Card */
.card{background:var(--bg1);border:1px solid var(--border);border-radius:var(--r);padding:15px;margin-bottom:14px;}
.card-hd{display:flex;align-items:baseline;gap:8px;margin-bottom:3px;}
.card-t{font-size:13px;font-weight:700;color:var(--text0);}
.card-sub{font-size:11px;color:var(--text2);margin-bottom:11px;}
.card canvas{max-height:240px;}
.card canvas.tall{max-height:300px;}
.card canvas.short{max-height:160px;}
/* Grids */
.g2{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;}
/* Advice */
.adv-list{display:flex;flex-direction:column;gap:8px;}
.adv{display:flex;gap:9px;padding:11px 13px;border-radius:var(--r);border-left:4px solid var(--border);background:var(--bg2);animation:fu .3s ease;}
@keyframes fu{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.adv.good{border-left-color:var(--green);background:var(--green-l);}
.adv.warn{border-left-color:var(--amber);background:var(--amber-l);}
.adv.bad {border-left-color:var(--red); background:var(--red-l);}
.adv.info{border-left-color:var(--blue); background:var(--blue-l);}
.adv.purple{border-left-color:var(--purple);background:var(--purple-l);}
.adv.teal {border-left-color:var(--teal); background:var(--teal-l);}
.adv-icon{font-size:17px;flex-shrink:0;}
.adv-t{font-weight:700;font-size:13px;color:var(--text0);margin-bottom:2px;}
.adv-d{font-size:12px;color:var(--text1);line-height:1.65;}
/* アクションボックス */
.action-box{margin-top:6px;padding:7px 10px;background:rgba(0,0,0,.2);border-radius:4px;font-size:11px;color:var(--amber);border-left:2px solid var(--amber);}
/* Heatmap */
.hm-wrap{overflow-x:auto;}
.heatmap{display:grid;grid-template-columns:48px repeat(24,1fr);gap:2px;min-width:680px;}
.hm-cell{height:26px;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:8px;font-family:'JetBrains Mono',monospace;cursor:default;transition:transform .1s;position:relative;}
.hm-cell:hover{transform:scale(1.12);z-index:2;}
.hm-lbl{background:transparent;font-size:10px;color:var(--text2);justify-content:flex-start;}
.hm-hdr{background:transparent;font-size:8px;color:var(--text3);height:18px;}
/* Rank table */
.rtbl{width:100%;border-collapse:collapse;font-size:12px;}
.rtbl th{font-size:9px;letter-spacing:.12em;color:var(--text3);text-align:left;padding:7px 9px;border-bottom:1px solid var(--border);font-family:'JetBrains Mono',monospace;text-transform:uppercase;}
.rtbl td{padding:8px 9px;border-bottom:1px solid var(--border);vertical-align:middle;}
.rtbl tr:hover td{background:var(--bg2);}
.rbar-w{height:5px;background:var(--bg3);border-radius:3px;min-width:70px;}
.rbar{height:5px;border-radius:3px;transition:width .6s;}
.badge{display:inline-flex;align-items:center;padding:2px 7px;border-radius:9px;font-size:10px;font-weight:600;white-space:nowrap;font-family:'JetBrains Mono',monospace;}
.bg{background:var(--green-l);color:var(--green);}
.br{background:var(--red-l);color:var(--red);}
.ba{background:var(--amber-l);color:var(--amber);}
.bb{background:var(--blue-l);color:var(--blue);}
.bp{background:var(--purple-l);color:var(--purple);}
.bt{background:var(--teal-l);color:var(--teal);}
/* Campaign effect */
.ce-wrap{display:grid;grid-template-columns:1fr auto 1fr;gap:0;align-items:center;margin-bottom:14px;}
.ce-box{padding:14px;text-align:center;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);}
.ce-arr{display:flex;flex-direction:column;align-items:center;padding:0 10px;}
.ce-chg{font-family:'JetBrains Mono',monospace;font-weight:600;font-size:18px;}
.ce-lbl{font-size:10px;color:var(--text2);margin-bottom:5px;}
.ce-val{font-family:'JetBrains Mono',monospace;font-size:20px;font-weight:600;color:var(--text0);}
/* Purchase */
.pw-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:3px;margin-top:10px;}
.pw-hdr{text-align:center;font-size:10px;color:var(--text2);padding:3px;font-family:'JetBrains Mono',monospace;}
.pw-cell{padding:7px 3px;text-align:center;border-radius:4px;font-size:10px;cursor:default;transition:transform .1s;}
.pw-cell:hover{transform:scale(1.06);}
.risk-bw{height:7px;background:var(--bg3);border-radius:4px;overflow:hidden;}
.risk-b{height:100%;border-radius:4px;transition:width .8s;}
/* Quick entry */
.qe-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:8px;}
.qe-btn{padding:14px 6px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);text-align:center;cursor:pointer;font-size:12px;color:var(--text1);transition:all .15s;}
.qe-btn:hover{border-color:var(--amber);color:var(--amber);}
.qe-entry{display:flex;flex-direction:column;gap:6px;margin-top:8px;}
/* Format modal */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;align-items:center;justify-content:center;}
.modal-bg.show{display:flex;}
.modal{background:var(--bg1);border:1px solid var(--border);border-radius:8px;padding:22px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;}
.modal h3{font-family:'Kaisei Opti',serif;font-size:16px;color:var(--amber);margin-bottom:14px;}
.modal code{font-family:'JetBrains Mono',monospace;font-size:11px;background:var(--bg0);padding:8px 12px;display:block;border-radius:4px;margin:6px 0;color:var(--text1);white-space:pre;}
.modal .close{float:right;cursor:pointer;color:var(--text2);font-size:18px;}
/* Empty */
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:240px;color:var(--text3);gap:9px;text-align:center;}
.empty-icon{font-size:40px;opacity:.35;}
/* Loading */
#loading{display:none;position:fixed;inset:0;background:rgba(18,16,14,.85);backdrop-filter:blur(4px);z-index:999;align-items:center;justify-content:center;flex-direction:column;gap:12px;}
#loading.show{display:flex;}
.lr{width:36px;height:36px;border:3px solid var(--border);border-top-color:var(--amber);border-radius:50%;animation:sp .75s linear infinite;}
@keyframes sp{to{transform:rotate(360deg)}}
/* Filter row */
.frow{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:12px;padding:9px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);}
.frow label{font-size:11px;color:var(--text2);white-space:nowrap;}
.frow select,.frow input[type=text]{flex:0 0 auto;width:auto;}
/* Col-map */
.colmap-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;}
.colmap-row label{font-size:11px;color:var(--text1);width:60px;flex-shrink:0;}
.colmap-row select{flex:1;}
.colmap-preview{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--teal);margin-top:4px;padding:5px 8px;background:var(--bg0);border-radius:4px;}
@media(max-width:900px){
.layout{grid-template-columns:1fr;}
.side{border-right:none;border-bottom:1px solid var(--border);}
.g2,.g3{grid-template-columns:1fr;}
.banner{grid-template-columns:auto 1fr;}
}
</style>
</head>
<body>
<div id="loading"><div class="lr"></div><div id="ldmsg" style="font-size:12px;color:var(--text2)">分析中...</div></div>
<!-- Format Modal -->
<div class="modal-bg" id="fmt-modal">
<div class="modal">
<span class="close" onclick="document.getElementById('fmt-modal').classList.remove('show')">✕</span>
<h3>📋 CSVフォーマットガイド</h3>
<p style="font-size:12px;color:var(--text1);margin-bottom:12px">以下のいずれかの形式に対応しています。ヘッダー行の有無は自動判定します。</p>
<p style="font-size:11px;color:var(--text2);margin-bottom:4px">▶ 最小構成(日付と金額だけ)</p>
<code>日付,金額
2024-01-15,1500
2024-01-15,980</code>
<p style="font-size:11px;color:var(--text2);margin-bottom:4px;margin-top:10px">▶ 標準(商品名付き)</p>
<code>日時,商品名,数量,金額
2024-01-15 12:30,コーヒー,2,760
2024-01-15 13:05,ランチ,1,850</code>
<p style="font-size:11px;color:var(--text2);margin-bottom:4px;margin-top:10px">▶ 詳細(カテゴリ付き)</p>
<code>日時,商品名,カテゴリ,数量,金額
2024-01-15 12:30,コーヒー,ドリンク,2,760</code>
<p style="font-size:11px;color:var(--text2);margin-bottom:4px;margin-top:10px">▶ 対応POSアプリ</p>
<p style="font-size:12px;color:var(--text1)"><b>Square:</b> 「レポート」→「売上サマリー」→「エクスポート」<br>
<b>Airレジ:</b> 「データ管理」→「売上データ」→「CSV出力」<br>
<b>Excel:</b> 「名前を付けて保存」→「CSV (コンマ区切り)」</p>
<p style="font-size:11px;color:var(--text3);margin-top:12px">※ 列の順番は問いません。列名から自動で判定します。判定できない場合は手動で設定できます。</p>
</div>
</div>
<!-- ── Header ── -->
<header>
<div class="logo">MISE</div>
<div class="logo-sub">小さなお店の販売分析 v2</div>
<div class="hdr-r">
<span id="store-disp" style="font-size:11px;color:var(--text3)">データ未読み込み</span>
<div id="hdr-kpis" style="display:none;display:flex;gap:8px">
<div class="hkpi"><div class="hkpi-l">総売上</div><div class="hkpi-v" id="hkv-s">─</div></div>
<div class="hkpi"><div class="hkpi-l">取引数</div><div class="hkpi-v" id="hkv-t">─</div></div>
<div class="hkpi"><div class="hkpi-l">客単価</div><div class="hkpi-v" id="hkv-a">─</div></div>
</div>
</div>
</header>
<div class="layout">
<!-- ══ Side ══ -->
<div class="side">
<div class="ss">
<div class="sl">店舗設定</div>
<div class="f"><label>店舗名</label><input type="text" id="storeName" placeholder="例: さくら食堂"></div>
<div class="f"><label>業種</label>
<select id="storeType">
<option value="food">飲食・カフェ</option>
<option value="retail">小売・雑貨</option>
<option value="grocery">食品・青果</option>
<option value="other">その他</option>
</select>
</div>
</div>
<div class="ss">
<div class="sl">CSVを読み込む</div>
<div class="drop-zone" id="dropZone"
onclick="document.getElementById('csvFile').click()"
ondragover="onDragOver(event)" ondrop="onDrop(event)">
<input type="file" id="csvFile" accept=".csv,.txt" onchange="loadCSV(this)">
<div style="font-size:20px;opacity:.55;margin-bottom:5px">📂</div>
<div style="font-weight:500">CSVをドロップ / クリックで選択</div>
<div style="font-size:10px;margin-top:5px;color:var(--text3);line-height:1.7">Square・Airレジ・Excel対応<br>日時 / 商品名 / 数量 / 金額</div>
</div>
<div style="display:flex;gap:5px;margin-top:7px">
<button class="btn btn-outline btn-sm" onclick="document.getElementById('fmt-modal').classList.add('show')" style="flex:1">📋 書式ガイド</button>
<button class="btn btn-outline btn-sm" onclick="loadSample()" style="flex:1">▶ サンプル</button>
</div>
</div>
<!-- 列マッピング(自動検出失敗時に表示) -->
<div class="ss" id="colmap-sec" style="display:none">
<div class="sl">列の対応を設定</div>
<div id="colmap-fields"></div>
<div class="colmap-preview" id="colmap-preview"></div>
<button class="btn btn-amber" onclick="applyColMap()" style="margin-top:7px">適用して分析</button>
</div>
<div class="ss">
<div class="sl">手入力(紙帳簿から)</div>
<div style="display:flex;gap:5px;margin-bottom:6px">
<button class="btn btn-outline btn-sm" onclick="showQuickEntry()" style="flex:1">+ 1件入力</button>
<button class="btn btn-outline btn-sm" onclick="showBulkEntry()" style="flex:1">📋 まとめ入力</button>
</div>
<div id="quick-entry-area" style="display:none">
<div class="f"><label>日時</label><input type="text" id="qe-dt" placeholder="2024-03-15 12:30"></div>
<div class="f"><label>商品名</label><input type="text" id="qe-prod" placeholder="コーヒー" list="prod-datalist"></div>
<datalist id="prod-datalist"></datalist>
<div class="f"><label>金額</label><input type="number" id="qe-amt" placeholder="480"></div>
<div class="f"><label>数量</label><input type="number" id="qe-qty" placeholder="1" value="1"></div>
<div style="display:flex;gap:5px">
<button class="btn btn-amber btn-sm" onclick="addQuickEntry()" style="flex:1">追加</button>
<button class="btn btn-outline btn-sm" onclick="document.getElementById('quick-entry-area').style.display='none'" style="flex:1">閉じる</button>
</div>
</div>
<div id="bulk-entry-area" style="display:none">
<div class="f">
<label>「日時,商品名,数量,金額」を1行ずつ</label>
<textarea id="bulk-text" placeholder="2024-03-15 12:30,コーヒー,1,480 2024-03-15 13:00,ランチ,1,850"></textarea>
</div>
<div style="display:flex;gap:5px">
<button class="btn btn-amber btn-sm" onclick="applyBulkEntry()" style="flex:1">読み込む</button>
<button class="btn btn-outline btn-sm" onclick="document.getElementById('bulk-entry-area').style.display='none'" style="flex:1">閉じる</button>
</div>
</div>
</div>
<div class="ss">
<div class="sl">分析期間</div>
<div class="f"><label>開始日</label><input type="date" id="fStart"></div>
<div class="f"><label>終了日</label><input type="date" id="fEnd"></div>
<button class="btn btn-outline" onclick="applyFilter()" style="margin-top:2px">絞り込む</button>
</div>
<div class="ss">
<div class="sl">キャンペーン登録</div>
<div class="f"><label>名称</label><input type="text" id="cpName" placeholder="例: 春のセール"></div>
<div class="f"><label>開始日</label><input type="date" id="cpStart"></div>
<div class="f"><label>終了日</label><input type="date" id="cpEnd"></div>
<button class="btn btn-amber" onclick="addCampaign()">登録</button>
<div class="cp-list" id="cp-list" style="margin-top:7px"></div>
</div>
<div class="ss">
<div class="sl">ログ</div>
<div id="log"></div>
</div>
</div><!-- /side -->
<!-- ══ Main ══ -->
<div class="main">
<!-- Banner -->
<div class="banner hidden" id="banner">
<div style="display:flex;flex-direction:column;align-items:center">
<div class="s-ring" id="s-ring">─</div>
<div class="s-lbl" id="s-lbl">─</div>
</div>
<div class="b-msg">
<div class="b-hl" id="b-hl">データを読み込んでください</div>
<div class="b-dt" id="b-dt"></div>
</div>
<div class="bkpi" id="bk1" style="display:none"><div class="bkpi-l">先週比</div><div class="bkpi-v" id="bk-wow">─</div><div class="bkpi-s">売上</div></div>
<div class="bkpi" id="bk2" style="display:none"><div class="bkpi-l">売れ筋</div><div class="bkpi-v" style="font-size:12px" id="bk-top">─</div><div class="bkpi-s">数量1位</div></div>
<div class="bkpi" id="bk3" style="display:none"><div class="bkpi-l">ピーク</div><div class="bkpi-v" id="bk-peak">─</div><div class="bkpi-s">最売上時間</div></div>
</div>
<!-- Tabs -->
<div class="tab-bar" id="main-tab-bar">
<div class="tab active" onclick="switchTab('overview',this)">📊 売上の流れ</div>
<div class="tab" onclick="switchTab('heatmap',this)">🕐 時間帯ヒートマップ</div>
<div class="tab" onclick="switchTab('products',this)">📦 商品ランキング</div>
<div class="tab" onclick="switchTab('campaign',this)">🎯 キャンペーン効果</div>
<div class="tab" onclick="switchTab('purchase',this)">🛒 仕入れ提案</div>
<div class="tab" onclick="switchTab('advice',this)">💡 アドバイス<span class="tbadge" id="adv-badge" style="display:none">0</span></div>
</div>
<!-- ── Overview ── -->
<div class="tab-content active" id="tab-overview">
<div class="empty" id="emp-overview"><div class="empty-icon">📊</div><div style="color:var(--text2)">CSVを読み込むか「サンプル」を押してください</div></div>
<div id="ov-content" style="display:none">
<div class="frow">
<label>集計</label>
<select id="ov-unit" onchange="buildOverview()"><option value="day">日次</option><option value="week">週次</option><option value="month" selected>月次</option></select>
<label style="margin-left:6px">商品</label>
<select id="ov-prod" onchange="buildOverview()" style="max-width:130px"><option value="all">すべて</option></select>
</div>
<div class="card">
<div class="card-t">売上推移</div>
<div class="card-sub" id="ov-sub">─</div>
<canvas id="ch-ov" class="tall"></canvas>
</div>
<div class="g2">
<div class="card"><div class="card-t">曜日別 平均売上</div><div class="card-sub">何曜日が稼ぎ頭か</div><canvas id="ch-dow"></canvas></div>
<div class="card"><div class="card-t">月別 平均売上</div><div class="card-sub">季節の波を把握する</div><canvas id="ch-mon"></canvas></div>
</div>
</div>
</div>
<!-- ── Heatmap ── -->
<div class="tab-content" id="tab-heatmap">
<div class="empty" id="emp-heatmap"><div class="empty-icon">🕐</div><div style="color:var(--text2)">時間付きデータが必要です(例: 2024-01-15 12:30)</div></div>
<div id="hm-content" style="display:none">
<div class="frow">
<label>指標</label>
<select id="hm-metric" onchange="buildHeatmap()"><option value="amount">売上金額</option><option value="qty">販売数量</option><option value="tx">取引件数</option></select>
<label style="margin-left:6px">商品</label>
<select id="hm-prod" onchange="buildHeatmap()" style="max-width:130px"><option value="all">すべて</option></select>
</div>
<div class="card">
<div class="card-t">曜日 × 時間帯 ヒートマップ</div>
<div class="card-sub">🔥 色が濃い=よく売れる。仕入れタイミング・人員配置の目安に。</div>
<div class="hm-wrap" id="hm-grid"></div>
</div>
<div class="g2">
<div class="card"><div class="card-t">時間帯別 売上合計</div><div class="card-sub">ピーク時間はここ</div><canvas id="ch-hour"></canvas></div>
<div class="card"><div class="card-t">時間帯別 売れ筋TOP3</div><div class="card-sub">時間帯ごとに何が売れているか</div><div id="hr-top3" style="margin-top:6px"></div></div>
</div>
</div>
</div>
<!-- ── Products ── -->
<div class="tab-content" id="tab-products">
<div class="empty" id="emp-products"><div class="empty-icon">📦</div><div style="color:var(--text2)">商品名付きのデータが必要です</div></div>
<div id="pr-content" style="display:none">
<div class="frow">
<label>並び順</label>
<select id="pr-sort" onchange="buildProducts()"><option value="amount">売上金額</option><option value="qty">販売数量</option><option value="freq">購入頻度</option></select>
<label style="margin-left:6px">カテゴリ</label>
<select id="pr-cat" onchange="buildProducts()" style="max-width:120px"><option value="all">すべて</option></select>
<label style="margin-left:6px">件数</label>
<select id="pr-lim" onchange="buildProducts()"><option value="10">10件</option><option value="20" selected>20件</option><option value="50">50件</option></select>
</div>
<div class="card">
<div class="card-t">商品ランキング</div>
<div class="card-sub" id="pr-sub">─</div>
<div style="overflow-x:auto">
<table class="rtbl">
<thead><tr><th style="width:32px">#</th><th>商品名</th><th>カテゴリ</th><th>売上金額</th><th style="min-width:90px">構成比</th><th>数量</th><th>単価</th><th>曜日傾向</th><th>判定</th></tr></thead>
<tbody id="pr-tbody"></tbody>
</table>
</div>
</div>
<div class="g2">
<div class="card"><div class="card-t">カテゴリ構成</div><div class="card-sub">どのカテゴリが主力か</div><canvas id="ch-cat"></canvas></div>
<div class="card"><div class="card-t">上位5商品 月別推移</div><div class="card-sub">主力商品の季節変動</div><canvas id="ch-toptrend"></canvas></div>
</div>
</div>
</div>
<!-- ── Campaign ── -->
<div class="tab-content" id="tab-campaign">
<div class="empty" id="emp-campaign"><div class="empty-icon">🎯</div><div style="color:var(--text2)">左パネルでキャンペーンを登録してください</div><div style="font-size:11px;color:var(--text3)">名称・開始日・終了日を入れるだけで自動比較</div></div>
<div id="cp-content" style="display:none">
<div id="cp-cards"></div>
<div class="card">
<div class="card-t">キャンペーン期間 売上比較チャート</div>
<div class="card-sub">全期間の推移にキャンペーン期間を重ねて表示</div>
<canvas id="ch-cp" class="tall"></canvas>
</div>
</div>
</div>
<!-- ── Purchase ── -->
<div class="tab-content" id="tab-purchase">
<div class="empty" id="emp-purchase"><div class="empty-icon">🛒</div><div style="color:var(--text2)">データを読み込むと仕入れ提案が表示されます</div></div>
<div id="pu-content" style="display:none">
<div class="g2">
<div class="card">
<div class="card-t">🗓️ 今週の仕入れ量目安</div>
<div class="card-sub">過去データから算出した曜日別の推奨仕入れ量</div>
<div class="pw-grid" id="pu-dow-grid"></div>
<div id="pu-dow-adv" style="margin-top:10px;display:flex;flex-direction:column;gap:6px"></div>
</div>
<div class="card">
<div class="card-t">🕐 品切れリスクの高い時間帯</div>
<div class="card-sub">需要が急増する時間帯。事前に補充を</div>
<div id="stockout-list" style="margin-top:6px;display:flex;flex-direction:column;gap:7px"></div>
</div>
</div>
<div class="card">
<div class="card-t">📦 商品別 仕入れ推奨量(1日あたり)</div>
<div class="card-sub">売れ行きの速さと売上貢献度から算出。変動が大きい商品は多めに確保を。</div>
<div style="overflow-x:auto;margin-top:8px">
<table class="rtbl">
<thead><tr><th>商品名</th><th>平均日販</th><th>ピーク曜日(×倍率)</th><th>推奨仕入れ量</th><th>変動</th><th>優先度</th><th>コメント</th></tr></thead>
<tbody id="pu-tbody"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-t">📅 季節別 仕入れカレンダー</div>
<div class="card-sub">月ごとの売上傾向から、いつ仕入れを増やすべきかがわかります</div>
<canvas id="ch-pu-season"></canvas>
<div id="pu-season-adv" style="margin-top:10px;display:flex;flex-direction:column;gap:6px"></div>
</div>
</div>
</div>
<!-- ── Advice ── -->
<div class="tab-content" id="tab-advice">
<div class="empty" id="emp-advice"><div class="empty-icon">💡</div><div style="color:var(--text2)">データを読み込むと自動でアドバイスが表示されます</div></div>
<div id="adv-content" style="display:none">
<div class="card" style="margin-bottom:14px">
<div class="card-t">📋 分析サマリー</div>
<div class="card-sub" id="adv-summary"></div>
</div>
<div class="adv-list" id="adv-list"></div>
</div>
</div>
</div><!-- /main -->
</div><!-- /layout -->
<script>
/* ══════════════════════════════════════════════
MISE v2 — 分析エンジン
対応: Square/Airレジ/Excel/手入力 10〜50種商品
══════════════════════════════════════════════ */
let allRows=[], filteredRows=[], campaigns=[], colMap={}, csvHeaders=[];
const CP_COLORS=['#e8a230','#5cb85a','#4a9fd4','#a07cd4','#e05050','#40b8a0'];
const DOW=['日','月','火','水','木','金','土'];
const MON=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
let charts={};
// ── utils ──────────────────────────────────────
const $=id=>document.getElementById(id);
const log=(msg,t='info')=>{const el=$('log');const d=document.createElement('div');d.className=t;d.textContent='> '+msg;el.appendChild(d);el.scrollTop=el.scrollHeight;};
const fY=n=>n==null||isNaN(n)?'─':'¥'+Math.round(n).toLocaleString();
const fN=(n,d=0)=>n==null||isNaN(n)?'─':n.toFixed(d).replace(/\B(?=(\d{3})+(?!\d))/g,',');
const fP=n=>(n>=0?'+':'')+n.toFixed(1)+'%';
const avg=a=>a.length?a.reduce((s,v)=>s+v,0)/a.length:0;
const sum=a=>a.reduce((s,v)=>s+v,0);
const std=a=>{const m=avg(a);return Math.sqrt(a.reduce((s,v)=>s+(v-m)**2,0)/(a.length||1));};
const dc=id=>{if(charts[id]){charts[id].destroy();delete charts[id];}};
// Chart.js defaults
const CD={
responsive:true,maintainAspectRatio:true,animation:{duration:450},
plugins:{
legend:{labels:{color:'#8a7a66',font:{family:"'IBM Plex Sans JP'",size:10},boxWidth:11}},
tooltip:{backgroundColor:'rgba(18,16,14,.95)',borderColor:'#3a342a',borderWidth:1,
titleColor:'#f0e8d8',bodyColor:'#c8b99a',padding:9,
titleFont:{family:"'JetBrains Mono'",size:10},bodyFont:{family:"'JetBrains Mono'",size:10}}
},
scales:{
x:{ticks:{color:'#5a4e40',font:{family:"'JetBrains Mono'",size:9},maxTicksLimit:14},grid:{color:'rgba(58,52,42,.4)'},border:{color:'#3a342a'}},
y:{ticks:{color:'#5a4e40',font:{family:"'JetBrains Mono'",size:9}},grid:{color:'rgba(58,52,42,.4)'},border:{color:'#3a342a'}}
}
};
function co(e={}){return JSON.parse(JSON.stringify({...CD,...e}));}
// ── CSV ────────────────────────────────────────
function onDragOver(e){e.preventDefault();$('dropZone').classList.add('drag');}
function onDrop(e){e.preventDefault();$('dropZone').classList.remove('drag');if(e.dataTransfer.files[0])parseFile(e.dataTransfer.files[0]);}
function loadCSV(i){if(i.files[0])parseFile(i.files[0]);}
function parseFile(f){
const r=new FileReader();
r.onload=e=>{try{parseText(e.target.result,f.name.replace(/\.csv$/i,''));}catch(err){log('読込エラー: '+err.message,'err');}};
r.readAsText(f,'UTF-8');
}
// ── Square / Airレジ / 汎用 自動認識 ────────────
function detectPOSFormat(headers){
// Square: "Date","Time","Category","Item","Qty","Price"
if(headers.some(h=>/^(Date|Time)$/i.test(h))&&headers.some(h=>/^(Item|Description)$/i.test(h))){
return 'square';
}
// Airレジ: 売上日時, 商品名, 金額(税込)
if(headers.some(h=>/売上日時/.test(h))&&headers.some(h=>/商品名/.test(h))){
return 'airregi';
}
return 'generic';
}
function autoMap(headers){
const m={datetime:-1,product:-1,category:-1,qty:-1,amount:-1};
const fmt=detectPOSFormat(headers);
if(fmt==='square'){
// Square形式: DateとTimeを結合する特別処理フラグ
m._squareDate=headers.findIndex(h=>/^Date$/i.test(h));
m._squareTime=headers.findIndex(h=>/^Time$/i.test(h));
m.datetime=m._squareDate; // Dateを基準に
m.product =headers.findIndex(h=>/^(Item|Description|Name)$/i.test(h));
m.category=headers.findIndex(h=>/^Category$/i.test(h));
m.qty =headers.findIndex(h=>/^(Qty|Quantity|Units)$/i.test(h));
m.amount =headers.findIndex(h=>/^(Gross Sales|Price|Amount|Net Sales|Total|売上)$/i.test(h));
if(m.amount<0) m.amount=headers.findIndex(h=>/price|sales|amount|total/i.test(h));
m._squareFmt=true;
log('Square形式を検出','ok');
return m;
}
if(fmt==='airregi'){
m.datetime=headers.findIndex(h=>/売上日時/.test(h));
m.product =headers.findIndex(h=>/商品名/.test(h));
m.category=headers.findIndex(h=>/カテゴリ|分類/.test(h));
m.qty =headers.findIndex(h=>/数量|個数/.test(h));
m.amount =headers.findIndex(h=>/金額|売上/.test(h));
log('Airレジ形式を検出','ok');
return m;
}
// 汎用マッピング
headers.forEach((h,i)=>{
const l=h.toLowerCase();
if(m.datetime<0&&/日時|日付|time|date|timestamp|売上日/i.test(h)) m.datetime=i;
if(m.product <0&&/商品|品名|item|product|メニュー|menu|name|品目/i.test(h)) m.product=i;
if(m.category<0&&/カテゴリ|category|分類|種別|genre|部門/i.test(h)) m.category=i;
if(m.qty <0&&/数量|個数|qty|quantity|点数|枚数|杯数|units/i.test(h)) m.qty=i;
if(m.amount <0&&/金額|売上|amount|price|revenue|合計|小計|税込|total|gross|net/i.test(h)) m.amount=i;
});
return m;
}
function parseText(text,name=''){
const lines=text.trim().split(/\r?\n/).filter(l=>l.trim());
if(lines.length<2) throw new Error('データが少なすぎます');
const first=splitLine(lines[0]);
const isHeader=first.some(c=>/[^\d\-\/: .,¥$]/.test(c));
csvHeaders=isHeader?first:first.map((_,i)=>`列${i+1}`);
const startRow=isHeader?1:0;
colMap=autoMap(csvHeaders);
log(`列検出: datetime=${colMap.datetime} product=${colMap.product} amount=${colMap.amount}`,'info');
if(colMap.datetime<0&&colMap.amount<0){
showColMapUI(csvHeaders,lines.slice(startRow,startRow+3).map(splitLine));
return;
}
const raw=[];
for(let i=startRow;i<lines.length;i++){
const cells=splitLine(lines[i]);
// Square: DateとTimeを結合
if(colMap._squareFmt&&colMap._squareTime>=0&&cells[colMap._squareDate]&&cells[colMap._squareTime]){
cells[colMap.datetime]=cells[colMap._squareDate]+' '+cells[colMap._squareTime];
}
const row=parseRow(cells,colMap);
if(row) raw.push(row);
}
if(!raw.length) throw new Error('有効な行がありません。書式ガイドを確認してください。');
raw.sort((a,b)=>(a.datetime||'').localeCompare(b.datetime||''));
allRows=raw; filteredRows=[...raw];
const sn=$('storeName').value||name||'お店のデータ';
$('storeName').value=sn;
const dates=raw.filter(r=>r.date).map(r=>r.date).sort();
if(dates.length){$('fStart').value=dates[0];$('fEnd').value=dates[dates.length-1];}
updateProdSelects(); updateCatSelect();
updateProdDatalist();
log(`読込完了: ${raw.length}件 (${dates[0]}〜${dates[dates.length-1]})`,'ok');
runAll();
}
function splitLine(line){
const res=[];let cur='',inQ=false;
for(let i=0;i<line.length;i++){
const c=line[i];
if(c==='"'){inQ=!inQ;}
else if(c===','&&!inQ){res.push(cur.trim());cur='';}
else cur+=c;
}
res.push(cur.trim());
return res.map(c=>c.replace(/^["']|["']$/g,'').trim());
}
function parseRow(cells,m){
try{
const dtStr=m.datetime>=0?cells[m.datetime]:'';
const dt=parseDT(dtStr);
const amtRaw=m.amount>=0?cells[m.amount]:null;
if(amtRaw==null) return null;
const amt=parseFloat(String(amtRaw).replace(/[¥,$\s,]/g,'').replace(/[^0-9.\-]/g,''));
if(isNaN(amt)||amt<0) return null;
const qtyRaw=m.qty>=0?cells[m.qty]:null;
const qty=qtyRaw!=null?parseFloat(qtyRaw):1;
const prod=m.product>=0?(cells[m.product]||'不明'):'不明';
const cat =m.category>=0?(cells[m.category]||'未分類'):'未分類';
return{datetime:dt.full,date:dt.date,hour:dt.hour,dow:dt.dow,month:dt.month,year:dt.year,product:prod,category:cat,qty:isNaN(qty)?1:qty,amount:amt};
}catch{return null;}
}
function parseDT(s){
if(!s)return{full:'',date:'',hour:null,dow:null,month:null,year:null};
s=s.replace(/\//g,'-').replace(/\s+/g,' ').trim();
const pats=[
/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})/,
/^(\d{4})-(\d{1,2})-(\d{1,2})/,
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})/,
/^(\d{1,2})-(\d{1,2})-(\d{4})\s+(\d{1,2}):(\d{2})/,
];
for(const p of pats){
const m=s.match(p);
if(m){
let y,mo,d,h=null;
if(p===pats[3]){mo=parseInt(m[1]);d=parseInt(m[2]);y=parseInt(m[3]);h=parseInt(m[4]);}
else{y=parseInt(m[1]);mo=parseInt(m[2]);d=parseInt(m[3]);if(m[4]!=null)h=parseInt(m[4]);}
const dt2=new Date(y,mo-1,d,h||0);
return{full:s,date:`${y}-${String(mo).padStart(2,'0')}-${String(d).padStart(2,'0')}`,
hour:h,dow:dt2.getDay(),month:mo,year:y};
}
}
return{full:s,date:'',hour:null,dow:null,month:null,year:null};
}
// ── 列マッピングUI ──────────────────────────────
function showColMapUI(headers,samples){
$('colmap-sec').style.display='block';
const roles=[{k:'datetime',l:'日時'},{k:'product',l:'商品名'},{k:'category',l:'カテゴリ'},{k:'qty',l:'数量'},{k:'amount',l:'金額'}];
$('colmap-fields').innerHTML=roles.map(r=>`
<div class="colmap-row">
<label>${r.l}</label>
<select id="cm-${r.k}">
<option value="-1">なし</option>
${headers.map((h,i)=>`<option value="${i}" ${colMap[r.k]==i?'selected':''}>${h}</option>`).join('')}
</select>
</div>`).join('');
// プレビュー
$('colmap-preview').textContent=samples[0]?'1行目: '+samples[0].join(' | '):'';
}
function applyColMap(){
['datetime','product','category','qty','amount'].forEach(k=>{
const el=$('cm-'+k);if(el)colMap[k]=parseInt(el.value);
});
$('colmap-sec').style.display='none';
if(allRows.length)runAll();
}
// ── Filter ─────────────────────────────────────
function applyFilter(){
const s=$('fStart').value,e=$('fEnd').value;
filteredRows=allRows.filter(r=>{if(s&&r.date<s)return false;if(e&&r.date>e)return false;return true;});
log(`絞り込み: ${filteredRows.length}件`,'ok');
runAll(false);
}
// ── 商品セレクト更新 ────────────────────────────
function updateProdSelects(){
const prods=[...new Set(filteredRows.map(r=>r.product))].sort();
['ov-prod','hm-prod'].forEach(id=>{
const sel=$(id);if(!sel)return;
const cur=sel.value;
sel.innerHTML='<option value="all">すべて</option>'+prods.map(p=>`<option value="${p}">${p}</option>`).join('');
sel.value=prods.includes(cur)?cur:'all';
});
}
function updateCatSelect(){
const cats=[...new Set(filteredRows.map(r=>r.category))].sort();
const sel=$('pr-cat');if(!sel)return;
const cur=sel.value;
sel.innerHTML='<option value="all">すべて</option>'+cats.map(c=>`<option value="${c}">${c}</option>`).join('');
sel.value=cats.includes(cur)?cur:'all';
}
function updateProdDatalist(){
const prods=[...new Set(allRows.map(r=>r.product))].sort();
$('prod-datalist').innerHTML=prods.map(p=>`<option value="${p}">`).join('');
}
// ── 手入力 ─────────────────────────────────────
function showQuickEntry(){$('quick-entry-area').style.display='block';$('bulk-entry-area').style.display='none';}
function showBulkEntry(){$('bulk-entry-area').style.display='block';$('quick-entry-area').style.display='none';}
function addQuickEntry(){
const dt=$('qe-dt').value.trim()||new Date().toISOString().slice(0,16).replace('T',' ');
const prod=$('qe-prod').value.trim()||'不明';
const amt=parseFloat($('qe-amt').value);
const qty=parseFloat($('qe-qty').value)||1;
if(isNaN(amt)){log('金額を入力してください','warn');return;}
const parsed=parseDT(dt);
const row={datetime:parsed.full||dt,date:parsed.date||dt.slice(0,10),
hour:parsed.hour,dow:parsed.dow,month:parsed.month,year:parsed.year,
product:prod,category:'手入力',qty,amount:amt};
allRows.push(row);
allRows.sort((a,b)=>(a.datetime||'').localeCompare(b.datetime||''));
filteredRows=[...allRows];
updateProdSelects();updateCatSelect();updateProdDatalist();
$('qe-dt').value='';$('qe-amt').value='';$('qe-qty').value='1';
log(`追加: ${prod} ${fY(amt)}`,'ok');
runAll(false);
}
function applyBulkEntry(){
const text=$('bulk-text').value.trim();
if(!text){log('データを入力してください','warn');return;}
try{parseText(text,'手入力データ');}catch(e){log(e.message,'err');}
$('bulk-entry-area').style.display='none';
}
// ── Campaign ────────────────────────────────────
function addCampaign(){
const name=$('cpName').value.trim(),s=$('cpStart').value,e=$('cpEnd').value;
if(!name||!s||!e){log('名称・開始日・終了日を入力してください','warn');return;}
if(s>e){log('開始日 > 終了日です','warn');return;}
campaigns.push({name,start:s,end:e,color:CP_COLORS[campaigns.length%CP_COLORS.length]});
$('cpName').value='';renderCPList();
if(allRows.length)buildCampaign();
log(`登録: ${name}`,'ok');
}
function renderCPList(){
const el=$('cp-list');
if(!campaigns.length){el.innerHTML='';return;}
el.innerHTML=campaigns.map((c,i)=>`
<div class="cp-item">
<div class="cp-dot" style="background:${c.color}"></div>
<div class="cp-name">${c.name}</div>
<div class="cp-dates">${c.start.slice(5)}〜${c.end.slice(5)}</div>
<div class="cp-del" onclick="delCP(${i})">✕</div>
</div>`).join('');
}
function delCP(i){campaigns.splice(i,1);renderCPList();if(allRows.length)buildCampaign();}
// ── Header / Banner ─────────────────────────────
function updateHeader(){
const sn=$('storeName').value||'お店';
$('store-disp').textContent=sn;
$('hdr-kpis').style.display='flex';
const totalAmt=sum(filteredRows.map(r=>r.amount));
const txSet=new Set(filteredRows.map(r=>r.datetime||r.date));
const txCnt=txSet.size||filteredRows.length;
$('hkv-s').textContent=fY(totalAmt);
$('hkv-t').textContent=txCnt.toLocaleString()+'件';
$('hkv-a').textContent=fY(totalAmt/txCnt);
}
function updateBanner(){
$('banner').classList.remove('hidden');
const rows=filteredRows;
const dates=[...new Set(rows.map(r=>r.date))].sort();
const last7=dates.slice(-7),prev7=dates.slice(-14,-7);
const wSum=ds=>sum(rows.filter(r=>ds.includes(r.date)).map(r=>r.amount));
const thisW=wSum(last7),prevW=wSum(prev7);
const wow=prevW>0?(thisW-prevW)/prevW*100:0;
let state,emoji,hl,dt2;
if(wow>8){state='good';emoji='📈';hl='売上は好調です';dt2=`先週比 ${fP(wow)} の上昇。この調子を維持しましょう。`;}
else if(wow>-5){state='warn';emoji='➡️';hl='売上は横ばい推移';dt2=`先週比 ${fP(wow)}。大きな変化はありません。`;}
else{state='bad';emoji='📉';hl='売上がやや下落しています';dt2=`先週比 ${fP(wow)}。仕入れや品揃えを見直すタイミングかもしれません。`;}
$('s-ring').className='s-ring '+state;$('s-ring').textContent=emoji;
$('s-lbl').textContent={good:'好調',warn:'横ばい',bad:'注意'}[state];
$('b-hl').textContent=hl;$('b-dt').textContent=dt2;
const wowEl=$('bk-wow');wowEl.textContent=fP(wow);wowEl.className='bkpi-v '+(wow>=0?'up':'down');
const byProd={};rows.forEach(r=>{byProd[r.product]=(byProd[r.product]||0)+r.qty;});
const topP=Object.entries(byProd).sort((a,b)=>b[1]-a[1])[0];
$('bk-top').textContent=topP?topP[0]:'─';
const hAmt={};rows.filter(r=>r.hour!=null).forEach(r=>{hAmt[r.hour]=(hAmt[r.hour]||0)+r.amount;});
const peakH=Object.entries(hAmt).sort((a,b)=>b[1]-a[1])[0];
$('bk-peak').textContent=peakH?peakH[0]+'時台':'─';
['bk1','bk2','bk3'].forEach(id=>$(id).style.display='flex');
}
// ── Sample ─────────────────────────────────────
function loadSample(){
allRows=genSample();filteredRows=[...allRows];
$('storeName').value='さくら食堂(サンプル)';
const dates=allRows.map(r=>r.date).sort();
$('fStart').value=dates[0];$('fEnd').value=dates[dates.length-1];
updateProdSelects();updateCatSelect();updateProdDatalist();
campaigns=[
{name:'春のランチ割引',start:'2024-03-01',end:'2024-03-31',color:'#e8a230'},
{name:'夏フェア',start:'2024-07-15',end:'2024-08-15',color:'#5cb85a'},
];
renderCPList();
log('サンプルデータ読込: '+allRows.length+'件(飲食店1年分)','ok');
runAll();
}
function genSample(){
const prods=[
{n:'日替わりランチ',c:'ランチ',bq:8,p:850,pk:[11,12,13]},
{n:'コーヒー',c:'ドリンク',bq:15,p:380,pk:[8,9,10,14,15]},
{n:'カフェラテ',c:'ドリンク',bq:10,p:450,pk:[9,10,11,15]},
{n:'アイスコーヒー',c:'ドリンク',bq:12,p:400,pk:[12,13,14,15]},
{n:'ケーキセット',c:'スイーツ',bq:6,p:750,pk:[14,15,16]},
{n:'モーニング',c:'モーニング',bq:10,p:680,pk:[7,8,9]},
{n:'パスタ',c:'ランチ',bq:5,p:950,pk:[12,13]},
{n:'サンドウィッチ',c:'ランチ',bq:4,p:620,pk:[11,12,13]},
{n:'プリン',c:'スイーツ',bq:7,p:320,pk:[14,15,16,17]},
{n:'紅茶',c:'ドリンク',bq:5,p:350,pk:[14,15,16]},
];
const sea=[.85,.82,.95,1.0,1.05,1.0,1.15,1.18,1.0,.98,1.05,1.25];
const df=[.7,.9,.95,.95,1.0,1.3,1.2];
const rows=[];
const s=new Date('2024-01-01'),e=new Date('2024-12-31');
for(let d=new Date(s);d<=e;d.setDate(d.getDate()+1)){
const dow=d.getDay(),mo=d.getMonth();
const ds=d.toISOString().slice(0,10);
const cp1=ds>='2024-03-01'&&ds<='2024-03-31';
const cp2=ds>='2024-07-15'&&ds<='2024-08-15';
const cb=cp1?1.25:cp2?1.18:1.0;
prods.forEach(p=>{
const hrs=dow===0||dow===6?[8,9,10,11,12,13,14,15,16,17,18]:[7,8,9,10,11,12,13,14,15,16,17,18];
hrs.forEach(h=>{
const ip=p.pk.includes(h);
const cnt=Math.max(0,Math.round(p.bq*(ip?1.6:.3)*sea[mo]*df[dow]*cb*(.7+Math.random()*.6)));
for(let i=0;i<cnt;i++)rows.push({
datetime:`${ds} ${String(h).padStart(2,'0')}:${String(Math.floor(Math.random()*60)).padStart(2,'0')}`,
date:ds,hour:h,dow,month:mo+1,year:2024,
product:p.n,category:p.c,qty:1,amount:p.p*(.95+Math.random()*.1)
});
});
});
}
return rows.sort((a,b)=>a.datetime.localeCompare(b.datetime));
}
// ── Tab switch ─────────────────────────────────
function switchTab(name,el){
$('main-tab-bar').querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
el.classList.add('active');
$('tab-'+name).classList.add('active');
}
// ── runAll ─────────────────────────────────────
function runAll(full=true){
$('loading').classList.add('show');
$('ldmsg').textContent='分析中...';
setTimeout(()=>{
try{
updateHeader();updateBanner();
if(full){buildOverview();buildHeatmap();buildProducts();buildCampaign();buildPurchase();buildAdvice();}
showAll();
}catch(e){log('エラー: '+e.message,'err');console.error(e);}
$('loading').classList.remove('show');
},60);
}
function setC(id,show){$('emp-'+id).style.display=show?'none':'flex';$(id.replace('-','') + '-content')?$(id.split('-')[0]+'-content').style.display=show?'block':'none':null;}
function showAll(){
const hasT=filteredRows.some(r=>r.hour!=null);
const hasP=filteredRows.some(r=>r.product&&r.product!=='不明');
[['emp-overview','ov-content',true],['emp-heatmap','hm-content',hasT],
['emp-products','pr-content',hasP],['emp-campaign','cp-content',campaigns.length>0],
['emp-purchase','pu-content',true],['emp-advice','adv-content',true]
].forEach(([eid,cid,show])=>{
$(eid).style.display=show?'none':'flex';
$(cid).style.display=show?'block':'none';
});
}
// ══════════════════════════════════════════════
// ─── 売上の流れ ─────────────────────────────
function buildOverview(){
const unit=$('ov-unit').value,prod=$('ov-prod').value;
let rows=filteredRows;
if(prod!=='all')rows=rows.filter(r=>r.product===prod);
const bp={};
rows.forEach(r=>{
let k;
if(unit==='day')k=r.date;
else if(unit==='week'){const d=new Date(r.date);d.setDate(d.getDate()-d.getDay());k=d.toISOString().slice(0,10);}
else k=r.date.slice(0,7);
if(!bp[k])bp[k]=0;bp[k]+=r.amount;
});
const labels=Object.keys(bp).sort(),amts=labels.map(k=>bp[k]);
const maN=Math.min(Math.max(3,Math.floor(labels.length/6)),8);
const ma=amts.map((_,i)=>i<maN-1?null:avg(amts.slice(i-maN+1,i+1)));
const rN=Math.min(5,Math.floor(labels.length/3));
const rA=avg(amts.slice(-rN)),oA=avg(amts.slice(-rN*2,-rN))||rA;
const tr=(rA-oA)/oA*100;
$('ov-sub').textContent=`${labels[0]}〜${labels[labels.length-1]} 直近傾向: ${Math.abs(tr)<2?'横ばい':tr>0?`上昇(${fP(tr)})`:` 下落(${fP(tr)})`}`;
dc('ov');
const ctx=$('ch-ov').getContext('2d');
const g=ctx.createLinearGradient(0,0,0,260);g.addColorStop(0,'rgba(232,162,48,.22)');g.addColorStop(1,'rgba(232,162,48,.01)');
charts['ov']=new Chart(ctx,{type:'line',data:{labels,datasets:[
{label:'売上',data:amts,borderColor:'#e8a230',borderWidth:2,backgroundColor:g,fill:true,tension:.3,pointRadius:amts.length>60?0:3,pointBackgroundColor:'#e8a230'},
{label:`${maN}期移動平均`,data:ma,borderColor:'#c8b99a',borderWidth:1.5,borderDash:[5,4],pointRadius:0,fill:false,tension:.3}
]},options:{...co(),plugins:{...CD.plugins,tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>c.dataset.label+': '+fY(c.raw)}}}}});
// 曜日
const dA=new Array(7).fill(0),dC=new Array(7).fill(0);
rows.forEach(r=>{if(r.dow!=null){dA[r.dow]+=r.amount;dC[r.dow]++;}});
const da=dA.map((a,i)=>dC[i]?a/dC[i]:0),mxD=Math.max(...da);
dc('dow');charts['dow']=new Chart($('ch-dow').getContext('2d'),{type:'bar',data:{labels:DOW,datasets:[{label:'平均売上',data:da,backgroundColor:da.map(v=>v>=mxD*.9?'rgba(232,162,48,.85)':v>=mxD*.7?'rgba(232,162,48,.55)':'rgba(90,78,64,.5)'),borderRadius:4,borderWidth:0}]},options:{...co(),plugins:{...CD.plugins,tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>fY(c.raw)}}}}});
// 月別
const mA=new Array(12).fill(0),mC=new Array(12).fill(0);
rows.forEach(r=>{if(r.month){mA[r.month-1]+=r.amount;mC[r.month-1]++;}});
const ma2=mA.map((a,i)=>mC[i]?a/mC[i]:0),totM=avg(ma2.filter(v=>v>0));
dc('mon');charts['mon']=new Chart($('ch-mon').getContext('2d'),{type:'bar',data:{labels:MON,datasets:[{label:'月平均',data:ma2,backgroundColor:ma2.map(v=>v>totM*1.1?'rgba(92,184,90,.75)':v<totM*.9?'rgba(224,80,80,.6)':'rgba(74,159,212,.6)'),borderRadius:4,borderWidth:0}]},options:{...co(),plugins:{...CD.plugins,tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>fY(c.raw)}}}}});
}
// ─── ヒートマップ ────────────────────────────
function buildHeatmap(){
const metric=$('hm-metric').value,prod=$('hm-prod').value;
let rows=filteredRows.filter(r=>r.hour!=null);
if(prod!=='all')rows=rows.filter(r=>r.product===prod);
if(!rows.length)return;
const mat=Array.from({length:7},()=>new Array(24).fill(0));
const cnt=Array.from({length:7},()=>new Array(24).fill(0));
rows.forEach(r=>{
if(r.dow==null||r.hour==null)return;
if(metric==='amount')mat[r.dow][r.hour]+=r.amount;
else if(metric==='qty')mat[r.dow][r.hour]+=r.qty;
else cnt[r.dow][r.hour]++;
});
const data=metric==='tx'?cnt:mat;
const all=data.flat().filter(v=>v>0),mx=Math.max(...all,1);
let html='<div class="heatmap"><div class="hm-cell hm-hdr"></div>';
for(let h=0;h<24;h++)html+=`<div class="hm-cell hm-hdr">${h}</div>`;
for(let d=0;d<7;d++){
html+=`<div class="hm-cell hm-lbl">${DOW[d]}</div>`;
for(let h=0;h<24;h++){
const v=data[d][h],r2=mx>0?v/mx:0;
const bg=r2<.05?'var(--bg3)':`rgba(${Math.round(210+v/mx*20)},${Math.round(100+v/mx*60)},${Math.round(20)},${.15+r2*.7})`;
const lbl=r2>.35?(metric==='amount'?`${Math.round(v/1000)}k`:Math.round(v)):'';
html+=`<div class="hm-cell" style="background:${bg};color:${r2>.6?'#12100e':'#8a7a66'}" title="${DOW[d]} ${h}時: ${metric==='amount'?fY(v):fN(v)}">${lbl}</div>`;
}
}
html+='</div>';
$('hm-grid').innerHTML=html;
// 時間帯棒グラフ
const hAmt=new Array(24).fill(0);
rows.forEach(r=>{hAmt[r.hour]+=r.amount;});
const pkH=hAmt.indexOf(Math.max(...hAmt));
dc('hour');charts['hour']=new Chart($('ch-hour').getContext('2d'),{type:'bar',data:{labels:[...Array(24).keys()].map(h=>h+'時'),datasets:[{label:'売上',data:hAmt,backgroundColor:hAmt.map((_,i)=>i===pkH?'rgba(232,162,48,.9)':'rgba(90,78,64,.5)'),borderRadius:3,borderWidth:0}]},options:{...co(),plugins:{...CD.plugins,tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>fY(c.raw)}}}}});
// 時間帯別TOP3
const hp={};rows.forEach(r=>{if(!hp[r.hour])hp[r.hour]={};hp[r.hour][r.product]=(hp[r.hour][r.product]||0)+r.amount;});
const topH=[...Array(24).keys()].sort((a,b)=>hAmt[b]-hAmt[a]).slice(0,5);
$('hr-top3').innerHTML=topH.map(h=>{
const ps=Object.entries(hp[h]||{}).sort((a,b)=>b[1]-a[1]).slice(0,3);
return `<div style="margin-bottom:8px"><div style="font-size:10px;color:var(--amber);font-family:'JetBrains Mono',monospace;margin-bottom:2px">▶ ${h}時台</div>${ps.map((p,i)=>`<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text1);padding:2px 0"><span>${i+1}. ${p[0]}</span><span style="color:var(--text2)">${fY(p[1])}</span></div>`).join('')}</div>`;
}).join('');
}
// ─── 商品ランキング ──────────────────────────
function buildProducts(){
const sort=$('pr-sort').value,cat=$('pr-cat').value,lim=parseInt($('pr-lim').value);
let rows=filteredRows;
if(cat!=='all')rows=rows.filter(r=>r.category===cat);
const bp={};
rows.forEach(r=>{
if(!bp[r.product])bp[r.product]={amt:0,qty:0,tx:0,cat:r.category,dates:new Set(),dows:new Array(7).fill(0),dowc:new Array(7).fill(0)};
bp[r.product].amt+=r.amount;bp[r.product].qty+=r.qty;bp[r.product].tx++;
bp[r.product].dates.add(r.date);
if(r.dow!=null){bp[r.product].dows[r.dow]+=r.amount;bp[r.product].dowc[r.dow]++;}
});
const total=sum(Object.values(bp).map(v=>v.amt));
let prods=Object.entries(bp).map(([n,v])=>({
n,cat:v.cat,amt:v.amt,qty:v.qty,tx:v.tx,freq:v.dates.size,
avgP:v.qty>0?v.amt/v.qty:0,
peakDow:v.dows.indexOf(Math.max(...v.dows)),
}));
if(sort==='amount')prods.sort((a,b)=>b.amt-a.amt);
else if(sort==='qty')prods.sort((a,b)=>b.qty-a.qty);
else prods.sort((a,b)=>b.freq-a.freq);
const top=prods.slice(0,lim);
$('pr-sub').textContent=`${prods.length}商品 / 合計 ${fY(total)}`;
const CC=['#e8a230','#5cb85a','#4a9fd4','#a07cd4','#e05050','#40b8a0','#e88a30','#30b8a0'];
$('pr-tbody').innerHTML=top.map((p,i)=>{
const pct=total>0?p.amt/total*100:0;
const tc=i<3?'var(--amber)':i<10?'var(--green)':'var(--text2)';
const badge=i===0?`<span class="badge ba">🥇</span>`:i===1?`<span class="badge bb">🥈</span>`:i===2?`<span class="badge bg">🥉</span>`:'';
return `<tr>
<td><span style="font-family:'JetBrains Mono',monospace;font-size:14px;font-weight:600;color:${tc}">${i+1}</span></td>
<td style="font-weight:500">${p.n} ${badge}</td>
<td><span class="badge bp">${p.cat}</span></td>
<td style="font-family:'JetBrains Mono',monospace;font-weight:600">${fY(p.amt)}</td>
<td><div style="display:flex;align-items:center;gap:6px"><div class="rbar-w" style="flex:1"><div class="rbar" style="width:${pct/Math.max(...top.map(x=>x.amt/total*100))*100}%;background:var(--amber)"></div></div><span style="font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2);width:32px;text-align:right">${fN(pct,1)}%</span></div></td>
<td style="font-family:'JetBrains Mono',monospace">${fN(p.qty)}</td>
<td style="font-family:'JetBrains Mono',monospace">${fY(p.avgP)}</td>
<td style="font-size:11px"><span class="badge ba">${DOW[p.peakDow]}曜</span></td>
<td>${i<Math.ceil(lim*.3)?'<span class="badge ba">主力</span>':i<Math.ceil(lim*.6)?'<span class="badge bb">安定</span>':'<span class="badge bg">補助</span>'}</td>
</tr>`;
}).join('');
// カテゴリ円グラフ
const byCat={};rows.forEach(r=>{byCat[r.category]=(byCat[r.category]||0)+r.amount;});
const cl=Object.keys(byCat),cv=cl.map(k=>byCat[k]);
dc('cat');charts['cat']=new Chart($('ch-cat').getContext('2d'),{type:'doughnut',data:{labels:cl,datasets:[{data:cv,backgroundColor:CC.slice(0,cl.length),borderColor:'var(--bg1)',borderWidth:2}]},options:{responsive:true,maintainAspectRatio:true,plugins:{legend:{...CD.plugins.legend,position:'right'},tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>`${c.label}: ${fY(c.raw)} (${fN(c.raw/sum(cv)*100,1)}%)`}}}}});
// 上位5月別
const top5=prods.slice(0,5);
const months=[...new Set(rows.map(r=>r.date.slice(0,7)))].sort();
dc('toptrend');charts['toptrend']=new Chart($('ch-toptrend').getContext('2d'),{type:'line',data:{labels:months,datasets:top5.map((p,i)=>({label:p.n,data:months.map(mo=>sum(rows.filter(r=>r.product===p.n&&r.date.startsWith(mo)).map(r=>r.amount))),borderColor:CC[i],borderWidth:1.5,pointRadius:2,fill:false,tension:.3}))},options:{...co(),plugins:{...CD.plugins,tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>c.dataset.label+': '+fY(c.raw)}}}}});
}
// ─── キャンペーン効果 ─────────────────────────
function buildCampaign(){
if(!campaigns.length){$('emp-campaign').style.display='flex';$('cp-content').style.display='none';return;}
$('emp-campaign').style.display='none';$('cp-content').style.display='block';
const cards=$('cp-cards');cards.innerHTML='';
campaigns.forEach((cp)=>{
const cpR=filteredRows.filter(r=>r.date>=cp.start&&r.date<=cp.end);
if(!cpR.length)return;
const d1=new Date(cp.start),d2=new Date(cp.end);
const days=Math.round((d2-d1)/86400000)+1;
const preE=new Date(d1);preE.setDate(preE.getDate()-1);
const preS=new Date(preE);preS.setDate(preS.getDate()-days+1);
const preR=filteredRows.filter(r=>r.date>=preS.toISOString().slice(0,10)&&r.date<=preE.toISOString().slice(0,10));
const postS=new Date(d2);postS.setDate(postS.getDate()+1);
const postE2=new Date(postS);postE2.setDate(postE2.getDate()+days-1);
const postR=filteredRows.filter(r=>r.date>=postS.toISOString().slice(0,10)&&r.date<=postE2.toISOString().slice(0,10));
const cpA=sum(cpR.map(r=>r.amount)),preA=sum(preR.map(r=>r.amount));
const postA=sum(postR.map(r=>r.amount));
const cpQ=sum(cpR.map(r=>r.qty));
const vs=preA>0?(cpA-preA)/preA*100:0;
const vsPost=postR.length&&postA>0?(cpA-postA)/postA*100:null;
// 商品別インパクト
const bpCp={},bpPre={};
cpR.forEach(r=>{bpCp[r.product]=(bpCp[r.product]||0)+r.amount;});
preR.forEach(r=>{bpPre[r.product]=(bpPre[r.product]||0)+r.amount;});
const lifts=Object.keys(bpCp).map(p=>({n:p,lift:bpPre[p]>0?(bpCp[p]-bpPre[p])/bpPre[p]*100:100})).sort((a,b)=>b.lift-a.lift).slice(0,3);
const ok=vs>=0;
// 次回推奨アクション
const nextAction=ok
? `✅ 効果あり!次回は「${lifts[0]?.n||'主力商品'}」の在庫を${Math.ceil(cpQ/days*1.3)}個/日以上確保し、${cp.end.slice(0,7)}以降も同様のキャンペーンを検討してください。`
: `⚠️ 効果が薄い結果でした。割引率を5〜10%上げる、または対象商品を「${lifts[0]?.n||'売れ筋'}」に絞り込むと効果が出やすくなります。`;
const el=document.createElement('div');el.className='card';
el.innerHTML=`
<div class="card-hd">
<div class="card-t">🎯 ${cp.name}</div>
<span class="badge ${ok?'bg':'br'}">${ok?'効果あり':'効果薄'}</span>
<span style="font-size:11px;color:var(--text2)">${cp.start}〜${cp.end}(${days}日間)</span>
</div>
<div class="ce-wrap">
<div class="ce-box"><div class="ce-lbl">キャンペーン前(同期間)</div><div class="ce-val">${fY(preA)}</div></div>
<div class="ce-arr"><div style="font-size:20px">→</div><div class="ce-chg" style="color:${ok?'var(--green)':'var(--red)'}">${fP(vs)}</div></div>
<div class="ce-box" style="border-color:${cp.color}"><div class="ce-lbl">キャンペーン中</div><div class="ce-val" style="color:${cp.color}">${fY(cpA)}</div></div>
</div>
${vsPost!=null?`<div style="font-size:11px;color:var(--text2);margin-bottom:10px">キャンペーン後との比較: <span style="color:${vsPost>=0?'var(--green)':'var(--red)'}">${fP(vsPost)}</span>(反動確認)</div>`:''}
${lifts.length?`<div style="margin-bottom:10px"><div style="font-size:11px;color:var(--text2);margin-bottom:5px">▶ 最も伸びた商品</div>${lifts.map((p,i)=>`<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px"><span>${i+1}. ${p.n}</span><span class="badge ${p.lift>=0?'bg':'br'}">${fP(p.lift)}</span></div>`).join('')}</div>`:''}
<div class="action-box">${nextAction}</div>
`;
cards.appendChild(el);
});
// 比較チャート
const months=[...new Set(filteredRows.map(r=>r.date.slice(0,7)))].sort();
const mAmt=months.map(m=>sum(filteredRows.filter(r=>r.date.startsWith(m)).map(r=>r.amount)));
dc('cp');
const ctx=$('ch-cp').getContext('2d');
const g=ctx.createLinearGradient(0,0,0,280);g.addColorStop(0,'rgba(232,162,48,.2)');g.addColorStop(1,'rgba(232,162,48,.01)');
charts['cp']=new Chart(ctx,{type:'line',data:{labels:months,datasets:[
{label:'月次売上',data:mAmt,borderColor:'#e8a230',borderWidth:2,backgroundColor:g,fill:true,tension:.3,pointRadius:4,pointBackgroundColor:'#e8a230'},
...campaigns.map(cp=>({
label:cp.name+'(期間)',
data:months.map(m=>{const inCp=m>=cp.start.slice(0,7)&&m<=cp.end.slice(0,7);return inCp?sum(filteredRows.filter(r=>r.date.startsWith(m)&&r.date>=cp.start&&r.date<=cp.end).map(r=>r.amount)):null;}),
borderColor:cp.color,borderWidth:3,backgroundColor:cp.color+'33',fill:true,tension:.3,pointRadius:5,pointBackgroundColor:cp.color,spanGaps:false
}))
]},options:{...co(),plugins:{...CD.plugins,tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>c.raw!=null?c.dataset.label+': '+fY(c.raw):''}}}}});
}
// ─── 仕入れ提案(具体的な数字) ──────────────
function buildPurchase(){
const rows=filteredRows;
const dA=new Array(7).fill(0),dC=new Array(7).fill(0);
rows.forEach(r=>{if(r.dow!=null){dA[r.dow]+=r.amount;dC[r.dow]++;}});
const da=dA.map((a,i)=>dC[i]?a/dC[i]:0),mxD=Math.max(...da,1);
// 曜日グリッド(仕入れ前日表示)
$('pu-dow-grid').innerHTML=
'<div class="pw-hdr" style="text-align:left">前日→</div>'+DOW.map((d,i)=>`<div class="pw-hdr">${d}→<br><span style="color:var(--amber)">${DOW[(i+1)%7]}</span>前</div>`).join('')+
'<div class="pw-hdr" style="text-align:left">仕入量</div>'+da.map((a,i)=>{
const r=a/mxD;const lv=r>.85?'多め':r>.6?'普通':'少なめ';
const bg=r>.85?'rgba(232,162,48,.7)':r>.6?'rgba(92,184,90,.5)':'rgba(58,52,42,.6)';
const col=r>.85?'#12100e':'#f0e8d8';
return `<div class="pw-cell" style="background:${bg};color:${col}" title="${DOW[i]}の平均: ${fY(a)}">${lv}</div>`;
}).join('')+
'<div class="pw-hdr" style="text-align:left">平均売上</div>'+da.map(a=>`<div class="pw-cell" style="font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text2)">${fY(a)}</div>`).join('');
// 曜日アドバイス
const topD=da.map((a,i)=>({i,a})).sort((a,b)=>b.a-a.a).slice(0,2);
$('pu-dow-adv').innerHTML=topD.map(({i,a})=>`
<div class="adv info"><div class="adv-icon">📅</div><div>
<div class="adv-t">${DOW[i]}曜が売上ピーク(平均 ${fY(a)})</div>
<div class="adv-d">${DOW[(i+6)%7]}曜(前日)に仕入れを多めに確保してください。特に主力商品の在庫は通常の1.5倍を目安に。</div>
</div></div>`).join('');
// 品切れリスク時間帯
const hA=new Array(24).fill(0),hC=new Array(24).fill(0);
rows.filter(r=>r.hour!=null).forEach(r=>{hA[r.hour]+=r.amount;hC[r.hour]++;});
const hAvg=hA.map((a,i)=>hC[i]?a/hC[i]:0);
const totH=avg(hAvg.filter(v=>v>0));
const riskH=hAvg.map((a,i)=>({h:i,a,r:a/(totH||1)})).filter(x=>x.r>1.3&&hC[x.h]>0).sort((a,b)=>b.r-a.r).slice(0,5);
$('stockout-list').innerHTML=riskH.map(x=>`
<div style="padding:7px 0;border-bottom:1px solid var(--border)">
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-family:'JetBrains Mono',monospace;color:var(--amber)">${x.h}時台</span>
<span class="badge ${x.r>1.7?'br':'ba'}">${x.r>1.7?'高リスク':'要注意'}</span>
</div>
<div class="risk-bw"><div class="risk-b" style="width:${Math.min(100,x.r/2*100)}%;background:${x.r>1.7?'var(--red)':'var(--amber)'}"></div></div>
<div style="font-size:10px;color:var(--text3);margin-top:2px">${x.h-1>=0?x.h-1+'時までに補充':' 開店前に補充'}推奨(平均需要の${fN(x.r*100,0)}%)</div>
</div>`).join('')||'<div style="font-size:12px;color:var(--text3)">目立ったリスク時間帯はありません</div>';
// 商品別仕入れ推奨量
const bP={};
rows.forEach(r=>{
if(!bP[r.product])bP[r.product]={amt:0,qty:0,dates:new Set(),dows:new Array(7).fill(0),dowc:new Array(7).fill(0)};
bP[r.product].amt+=r.amount;bP[r.product].qty+=r.qty;bP[r.product].dates.add(r.date);
if(r.dow!=null){bP[r.product].dows[r.dow]+=r.amount;bP[r.product].dowc[r.dow]++;}
});
const allD=[...new Set(rows.map(r=>r.date))];
const totA=sum(Object.values(bP).map(v=>v.amt));
const prods=Object.entries(bP).map(([n,v])=>{
const dayAvgQty=v.qty/allD.length;
const da2=v.dows.map((a,i)=>v.dowc[i]?a/v.dowc[i]:0);
const pkDow=da2.indexOf(Math.max(...da2));
const pkMult=da2[pkDow]/(avg(da2.filter(x=>x>0))||1);
const vol=std(da2.filter(x=>x>0))/(avg(da2.filter(x=>x>0))||1);
const contrib=v.amt/totA;
const priority=contrib*.6+(dayAvgQty>.5?.3:.1)+(vol>.4?.1:.05);
// 推奨仕入れ量: 日平均+バッファ(変動大→+30%、普通→+15%)
const buf=vol>.4?1.3:vol>.2?1.15:1.1;
const rec=Math.ceil(dayAvgQty*buf);
return{n,dayAvgQty,pkDow,pkMult,vol,contrib,priority,rec};
}).sort((a,b)=>b.priority-a.priority).slice(0,15);
$('pu-tbody').innerHTML=prods.map(p=>`<tr>
<td style="font-weight:500">${p.n}</td>
<td style="font-family:'JetBrains Mono',monospace">${fN(p.dayAvgQty,1)}個</td>
<td>${DOW[p.pkDow]}曜 <span class="badge ba">×${fN(p.pkMult,1)}</span></td>
<td style="font-family:'JetBrains Mono',monospace;font-weight:600;color:var(--amber)">${p.rec}個/日</td>
<td><span class="badge ${p.vol>.4?'br':p.vol>.2?'ba':'bg'}">${p.vol>.4?'変動大':p.vol>.2?'普通':'安定'}</span></td>
<td><span class="badge ${p.priority>prods[0].priority*.7?'ba':p.priority>prods[0].priority*.4?'bb':'bg'}">${p.priority>prods[0].priority*.7?'最優先':p.priority>prods[0].priority*.4?'優先':'通常'}</span></td>
<td style="font-size:11px;color:var(--text2)">${DOW[(p.pkDow+6)%7]}曜に${p.rec}個仕入${p.vol>.4?' (変動注意)':''}</td>
</tr>`).join('');
// 季節
const mA2=new Array(12).fill(0),mC2=new Array(12).fill(0);
rows.forEach(r=>{if(r.month){mA2[r.month-1]+=r.amount;mC2[r.month-1]++;}});
const mAvg=mA2.map((a,i)=>mC2[i]?a/mC2[i]:0),tmA=avg(mAvg.filter(v=>v>0));
dc('pu-season');charts['pu-season']=new Chart($('ch-pu-season').getContext('2d'),{type:'bar',data:{labels:MON,datasets:[{label:'月平均売上',data:mAvg,backgroundColor:mAvg.map(v=>v>tmA*1.12?'rgba(232,162,48,.85)':v<tmA*.88?'rgba(74,159,212,.6)':'rgba(90,78,64,.5)'),borderRadius:4,borderWidth:0}]},options:{...co(),plugins:{...CD.plugins,tooltip:{...CD.plugins.tooltip,callbacks:{label:c=>fY(c.raw)}}}}});
const hiM=mAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>tmA*1.1).sort((a,b)=>b.a-a.a);
const loM=mAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>0&&x.a<tmA*.9).sort((a,b)=>a.a-b.a);
const sa=[];
if(hiM.length)sa.push({t:'warn',i:'📈',tl:`${hiM.map(x=>MON[x.m]).slice(0,3).join('・')}は需要が高い時期`,d:`平均より${fN((hiM[0].a/tmA-1)*100,0)}%多く売れます。1〜2週間前から仕入れを増やし、品切れによる機会損失を防ぎましょう。`});
if(loM.length)sa.push({t:'info',i:'📉',tl:`${loM.map(x=>MON[x.m]).slice(0,3).join('・')}は閑散期`,d:`売上が平均より${fN((1-loM[0].a/tmA)*100,0)}%低い傾向。仕入れを絞って廃棄ロスを減らすか、この時期に合ったキャンペーンを検討しましょう。`});
$('pu-season-adv').innerHTML=sa.map(a=>`<div class="adv ${a.t}"><div class="adv-icon">${a.i}</div><div><div class="adv-t">${a.tl}</div><div class="adv-d">${a.d}</div></div></div>`).join('');
}
// ─── アドバイス ──────────────────────────────
function buildAdvice(){
const rows=filteredRows;
const advs=[];
const dates=[...new Set(rows.map(r=>r.date))].sort();
const totalAmt=sum(rows.map(r=>r.amount));
const rN=Math.min(7,Math.floor(dates.length/3));
const rD=dates.slice(-rN),oD=dates.slice(-rN*2,-rN);
const rA=sum(rows.filter(r=>rD.includes(r.date)).map(r=>r.amount));
const oA=sum(rows.filter(r=>oD.includes(r.date)).map(r=>r.amount));
const tr=oA>0?(rA-oA)/oA*100:0;
if(tr>10)advs.push({t:'good',i:'📈',tl:'売上が伸びています',d:`直近${rN}日間は前の同期間より ${fP(tr)} 増加。何が効いているか記録しておきましょう。`,a:'この成功パターンを記録して、来月の仕入れ計画に活かしてください。'});
else if(tr<-10)advs.push({t:'bad',i:'⚠️',tl:'売上が下落傾向',d:`直近${rN}日間は前の同期間より ${fP(tr)} 減少。品揃えや接客を見直す時期です。`,a:'主力商品(売上TOP3)だけでも欠品がないか、今すぐ在庫確認を。'});
// ピーク時間帯
const hA={};rows.filter(r=>r.hour!=null).forEach(r=>{hA[r.hour]=(hA[r.hour]||0)+r.amount;});
const pkH=Object.entries(hA).sort((a,b)=>b[1]-a[1])[0];
if(pkH)advs.push({t:'teal',i:'⏰',tl:`${pkH[0]}時台が最もよく売れる時間帯`,d:`全体売上の ${fN(pkH[1]/totalAmt*100,1)}%。この時間帯の品切れは最大の機会損失につながります。`,a:`${pkH[0]-1>=0?pkH[0]-1:'開店前'}時までに主力商品の補充を完了させましょう。`});
// 曜日
const dA=new Array(7).fill(0),dC=new Array(7).fill(0);
rows.forEach(r=>{if(r.dow!=null){dA[r.dow]+=r.amount;dC[r.dow]++;}});
const da=dA.map((a,i)=>dC[i]?a/dC[i]:0);
const pkD=da.indexOf(Math.max(...da)),wkD=da.indexOf(Math.min(...da.filter(v=>v>0)));
advs.push({t:'info',i:'📅',tl:`${DOW[pkD]}曜日が売上ピーク(平均 ${fY(da[pkD])})`,d:`${DOW[(pkD+6)%7]}曜(前日)に仕入れを多めに。${DOW[wkD]}曜(平均 ${fY(da[wkD])})は仕入れを絞ると廃棄ロスを減らせます。`,a:`${DOW[(pkD+6)%7]}曜の発注量を現在の1.3〜1.5倍に設定してみましょう。`});
// パレート
const bP={};rows.forEach(r=>{bP[r.product]=(bP[r.product]||0)+r.amount;});
const ps=Object.entries(bP).sort((a,b)=>b[1]-a[1]);
const t20=Math.ceil(ps.length*.2),t20A=sum(ps.slice(0,t20).map(x=>x[1]));
advs.push({t:'purple',i:'📦',tl:`上位${t20}商品が売上の ${fN(t20A/totalAmt*100,0)}% を占めています`,d:`「${ps.slice(0,3).map(x=>x[0]).join('・')}」が特に重要。これらの品切れが最も痛手になります。`,a:`上位商品の在庫は必ず2日分以上確保するルールを作りましょう。`});
// 季節
const mA=new Array(12).fill(0),mC=new Array(12).fill(0);
rows.forEach(r=>{if(r.month){mA[r.month-1]+=r.amount;mC[r.month-1]++;}});
const mAvg=mA.map((a,i)=>mC[i]?a/mC[i]:0),tmA=avg(mAvg.filter(v=>v>0));
const hiM=mAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>tmA*1.12).sort((a,b)=>b.a-a.a);
const loM=mAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>0&&x.a<tmA*.88).sort((a,b)=>a.a-b.a);
if(hiM.length)advs.push({t:'warn',i:'🌸',tl:`${hiM.map(x=>MON[x.m]).slice(0,3).join('・')}は繁忙期`,d:`平均より${fN((hiM[0].a/tmA-1)*100,0)}%多く売れる時期。1〜2週間前から仕入れ量を増やしてください。`,a:`${MON[hiM[0].m]}の仕入れは通常の${Math.round((hiM[0].a/tmA)*10)/10}倍を目安に計画しましょう。`});
if(loM.length)advs.push({t:'info',i:'🍂',tl:`${loM.map(x=>MON[x.m]).slice(0,3).join('・')}は閑散期`,d:`売上が平均より${fN((1-loM[0].a/tmA)*100,0)}%低い傾向。仕入れを絞って廃棄ロスを最小化しましょう。`,a:`この時期はキャンペーンを打つか、仕入れ量を${Math.round((1-(loM[0].a/tmA))*100)}%削減するか選択してください。`});
// キャンペーン振り返り
if(campaigns.length){
const effects=campaigns.map(cp=>{
const cA=sum(filteredRows.filter(r=>r.date>=cp.start&&r.date<=cp.end).map(r=>r.amount));
const d1=new Date(cp.start),d2=new Date(cp.end);const days=Math.round((d2-d1)/86400000)+1;
const pE=new Date(d1);pE.setDate(pE.getDate()-1);const pS=new Date(pE);pS.setDate(pS.getDate()-days+1);
const pA=sum(filteredRows.filter(r=>r.date>=pS.toISOString().slice(0,10)&&r.date<=pE.toISOString().slice(0,10)).map(r=>r.amount));
return{name:cp.name,lift:pA>0?(cA-pA)/pA*100:0};
}).sort((a,b)=>b.lift-a.lift);
const best=effects[0];
if(best.lift>5)advs.push({t:'good',i:'🎯',tl:`「${best.name}」が効果的でした`,d:`前回同期間比 ${fP(best.lift)} の売上増。このキャンペーンは成功パターンです。`,a:'同じ時期・内容で来年も実施することを検討してください。仕入れも1.2〜1.5倍に増やすと良いでしょう。'});
else if(best.lift<-5)advs.push({t:'bad',i:'🎯',tl:'キャンペーン効果が出ていません',d:`「${best.name}」は前回比 ${fP(best.lift)}。告知方法・割引率・対象商品の見直しが必要です。`,a:'次回は割引率を5〜10%上げるか、SNSでの告知を強化してみましょう。'});
}
// 客単価
const txS={};rows.forEach(r=>{const k=r.datetime||r.date;if(!txS[k])txS[k]=0;txS[k]+=r.amount;});
const txA=Object.values(txS),avgTx=avg(txA);
if(avgTx<800)advs.push({t:'warn',i:'💰',tl:`客単価 ${fY(avgTx)} — アップセルの余地あり`,d:'「もう1品いかがですか?」の声がけやセットメニューで単価を上げましょう。',a:`客単価を100円上げるだけで、取引${txA.length}件 × 100円 = ${fY(txA.length*100)}の売上増になります。`});
// サマリー
$('adv-summary').textContent=`期間: ${dates[0]}〜${dates[dates.length-1]}(${dates.length}日間) 総売上: ${fY(totalAmt)} 取引: ${txA.length}件 客単価: ${fY(avgTx)} 商品種類: ${ps.length}種`;
// 描画(アクションボックス付き)
$('adv-list').innerHTML=advs.map(a=>`
<div class="adv ${a.t}">
<div class="adv-icon">${a.i}</div>
<div style="flex:1">
<div class="adv-t">${a.tl}</div>
<div class="adv-d">${a.d}</div>
${a.a?`<div class="action-box">▶ 次のアクション: ${a.a}</div>`:''}
</div>
</div>`).join('');
const badge=$('adv-badge');badge.textContent=advs.length;badge.style.display='inline-flex';
$('emp-advice').style.display='none';$('adv-content').style.display='block';
}
// ── init ────────────────────────────────────────
log('「サンプル」ボタンでデモを確認できます','info');
log('Square・Airレジ・ExcelのCSVを直接ドロップできます','info');
</script>
</body>
</html>

コメント