お店の売上分析ツール「Mise」
【更新履歴】
・2026/2/20 バージョン1.0公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MISE — 小さなお店の販売分析</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>
/* ══════════════════════════════════════════════
MISE Design System — 温かみのある実務的ダークUI
══════════════════════════════════════════════ */
: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.15);
--amber-d: #b87d1a;
--green: #5cb85a;
--green-l: rgba(92,184,90,0.13);
--red: #e05050;
--red-l: rgba(224,80,80,0.13);
--blue: #4a9fd4;
--blue-l: rgba(74,159,212,0.13);
--purple: #a07cd4;
--purple-l:rgba(160,124,212,0.13);
--teal: #40b8a0;
--teal-l: rgba(64,184,160,0.13);
--radius: 6px;
--shadow: 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: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg1); }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
/* ══ ヘッダー ══════════════════════════════════ */
header {
position: sticky; top: 0; z-index: 300;
background: var(--bg1);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 20px;
padding: 0 24px; height: 52px;
}
.logo {
font-family: 'Kaisei Opti', serif;
font-size: 20px; letter-spacing: 0.12em;
color: var(--amber);
white-space: nowrap;
}
.logo-sub {
font-size: 10px; color: var(--text2);
letter-spacing: 0.2em; text-transform: uppercase;
border-left: 1px solid var(--border); padding-left: 16px;
white-space: nowrap;
}
.header-right {
margin-left: auto;
display: flex; align-items: center; gap: 16px;
}
#store-name-display {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; color: var(--text1);
}
.header-kpi {
display: flex; gap: 12px;
}
.hkpi {
display: flex; flex-direction: column; align-items: flex-end;
padding: 4px 10px;
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius);
}
.hkpi-label { font-size: 9px; color: var(--text2); letter-spacing: 0.1em; text-transform: uppercase; }
.hkpi-val { font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 600; color: var(--amber); }
/* ══ レイアウト ════════════════════════════════ */
.layout {
display: grid;
grid-template-columns: 280px 1fr;
min-height: calc(100vh - 52px);
}
/* ══ サイドパネル ══════════════════════════════ */
.side {
background: var(--bg1);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
display: flex; flex-direction: column; gap: 14px;
}
.side-sec {
border-bottom: 1px solid var(--border);
padding-bottom: 14px;
}
.side-sec:last-child { border-bottom: none; }
.side-label {
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--text3); margin-bottom: 8px;
font-family: 'JetBrains Mono', monospace;
}
/* フォーム要素 */
.field { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; }
.field 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(--radius); padding: 7px 10px;
font-family: inherit; font-size: 12px; color: var(--text0);
outline: none; width: 100%;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--amber);
}
select option { background: var(--bg2); }
textarea { resize: vertical; min-height: 54px; font-size: 11px; }
.btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 14px; border-radius: var(--radius);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer; border: none; width: 100%;
transition: all 0.15s; white-space: nowrap;
}
.btn-amber { background: var(--amber); color: #12100e; }
.btn-amber:hover { background: #f0b040; box-shadow: 0 2px 12px rgba(232,162,48,0.35); }
.btn-outline { background: transparent; color: var(--text1); border: 1px solid var(--border); }
.btn-outline:hover { border-color: var(--border2); background: var(--bg2); }
.btn-sm { padding: 5px 10px; font-size: 11px; width: auto; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ドロップゾーン */
.drop-zone {
border: 2px dashed var(--border2);
border-radius: var(--radius); padding: 16px 12px;
text-align: center; cursor: pointer;
color: var(--text2); font-size: 12px;
background: var(--bg2); transition: all 0.2s;
}
.drop-zone:hover, .drop-zone.drag {
border-color: var(--amber); color: var(--amber);
background: var(--amber-l);
}
.drop-zone input { display: none; }
/* ログ */
#log {
font-family: 'JetBrains Mono', monospace; font-size: 10px;
color: var(--text2); background: var(--bg0);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 8px; max-height: 72px; 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 { display: flex; flex-direction: column; gap: 4px; max-height: 130px; overflow-y: auto; }
.campaign-item {
display: flex; align-items: center; gap: 6px;
padding: 5px 8px; background: var(--bg2);
border: 1px solid var(--border); border-radius: var(--radius);
font-size: 11px;
}
.campaign-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.campaign-name { flex: 1; color: var(--text1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.campaign-dates { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); }
.campaign-del { cursor: pointer; color: var(--text3); padding: 0 2px; }
.campaign-del:hover { color: var(--red); }
/* ══ メインエリア ══════════════════════════════ */
.main { display: flex; flex-direction: column; overflow-y: auto; }
/* ── ステータスバナー ── */
.banner {
background: linear-gradient(135deg, var(--bg2) 0%, var(--bg1) 100%);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: grid; grid-template-columns: auto 1fr repeat(4, auto);
gap: 16px; align-items: center;
}
.banner.hidden { display: none; }
.banner-state {
display: flex; flex-direction: column; align-items: center; gap: 4px;
}
.state-ring {
width: 48px; height: 48px; border-radius: 50%;
border: 2px solid var(--border2); background: var(--bg2);
display: flex; align-items: center; justify-content: center;
font-size: 24px; transition: all 0.4s;
}
.state-ring.good { border-color: var(--green); background: var(--green-l); box-shadow: 0 0 14px rgba(92,184,90,0.2); }
.state-ring.warn { border-color: var(--amber); background: var(--amber-l); box-shadow: 0 0 14px rgba(232,162,48,0.2); }
.state-ring.bad { border-color: var(--red); background: var(--red-l); box-shadow: 0 0 14px rgba(224,80,80,0.2); }
.state-label { font-size: 9px; color: var(--text2); letter-spacing: 0.1em; }
.banner-msg { padding-left: 8px; }
.banner-headline {
font-family: 'Kaisei Opti', serif; font-size: 16px;
color: var(--text0); line-height: 1.4;
}
.banner-detail { font-size: 12px; color: var(--text2); margin-top: 3px; }
.bkpi {
display: flex; flex-direction: column; align-items: flex-end;
padding: 8px 14px; background: var(--bg0);
border: 1px solid var(--border); border-radius: var(--radius);
min-width: 110px;
}
.bkpi-label { font-size: 9px; color: var(--text3); letter-spacing: 0.1em; text-transform: uppercase; }
.bkpi-val { font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 600; color: var(--text0); margin-top: 1px; }
.bkpi-val.up { color: var(--green); }
.bkpi-val.down { color: var(--red); }
.bkpi-sub { font-size: 10px; color: var(--text3); }
/* ══ タブ ══════════════════════════════════════ */
.tab-bar {
display: flex; overflow-x: auto; gap: 0;
background: var(--bg1); border-bottom: 1px solid var(--border);
padding: 0 20px;
position: sticky; top: 52px; z-index: 200;
}
.tab-bar::-webkit-scrollbar { height: 2px; }
.tab {
padding: 12px 16px; font-size: 12px; cursor: pointer;
color: var(--text2); border-bottom: 2px solid transparent;
white-space: nowrap; transition: all 0.15s; font-weight: 500;
display: flex; align-items: center; gap: 6px;
}
.tab:hover { color: var(--text1); }
.tab.active { color: var(--amber); border-bottom-color: var(--amber); }
.tab-badge {
background: var(--red); color: #fff;
font-size: 9px; padding: 1px 5px; border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
}
/* ══ タブコンテンツ ══ */
.tab-content { display: none; padding: 20px 24px; }
.tab-content.active { display: block; }
/* ── カード ── */
.card {
background: var(--bg1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px;
margin-bottom: 16px;
}
.card-header {
display: flex; align-items: baseline; gap: 8px;
margin-bottom: 4px;
}
.card-title { font-size: 13px; font-weight: 700; color: var(--text0); }
.card-sub { font-size: 11px; color: var(--text2); margin-bottom: 12px; }
.card canvas { max-height: 260px; }
.card canvas.tall { max-height: 320px; }
.card canvas.short { max-height: 180px; }
/* ── グリッド ── */
.g2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.g3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
/* ── 気づきカード ── */
.advice-list { display: flex; flex-direction: column; gap: 8px; }
.advice {
display: flex; gap: 10px; padding: 12px 14px;
border-radius: var(--radius);
border-left: 4px solid var(--border);
background: var(--bg2);
animation: fadeUp 0.3s ease;
}
@keyframes fadeUp { from { opacity:0; transform:translateY(3px); } to { opacity:1; transform:none; } }
.advice.good { border-left-color: var(--green); background: var(--green-l); }
.advice.warn { border-left-color: var(--amber); background: var(--amber-l); }
.advice.bad { border-left-color: var(--red); background: var(--red-l); }
.advice.info { border-left-color: var(--blue); background: var(--blue-l); }
.advice.purple { border-left-color: var(--purple); background: var(--purple-l); }
.advice.teal { border-left-color: var(--teal); background: var(--teal-l); }
.advice-icon { font-size: 18px; flex-shrink: 0; }
.advice-title { font-weight: 700; font-size: 13px; color: var(--text0); margin-bottom: 3px; }
.advice-desc { font-size: 12px; color: var(--text1); line-height: 1.65; }
/* ── ヒートマップ ── */
.heatmap-wrap { overflow-x: auto; }
.heatmap {
display: grid;
grid-template-columns: 60px repeat(24, 1fr);
gap: 2px; min-width: 700px;
}
.hm-cell {
height: 28px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-family: 'JetBrains Mono', monospace;
cursor: default; transition: transform 0.1s;
position: relative;
}
.hm-cell:hover { transform: scale(1.15); z-index: 2; }
.hm-label { background: transparent; font-size: 10px; color: var(--text2); justify-content: flex-start; }
.hm-header { background: transparent; font-size: 9px; color: var(--text3); height: 20px; }
/* ── 商品ランキング ── */
.rank-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.rank-table th {
font-size: 9px; letter-spacing: 0.12em; color: var(--text3);
text-align: left; padding: 8px 10px;
border-bottom: 1px solid var(--border);
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
}
.rank-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.rank-table tr:hover td { background: var(--bg2); }
.rank-num {
font-family: 'JetBrains Mono', monospace; font-weight: 600;
font-size: 14px;
}
.rank-bar-wrap { height: 6px; background: var(--bg3); border-radius: 3px; min-width: 80px; }
.rank-bar { height: 6px; border-radius: 3px; transition: width 0.6s; }
.badge {
display: inline-flex; align-items: center;
padding: 2px 8px; border-radius: 10px;
font-size: 10px; font-weight: 600; white-space: nowrap;
font-family: 'JetBrains Mono', monospace;
}
.badge-green { background: var(--green-l); color: var(--green); }
.badge-red { background: var(--red-l); color: var(--red); }
.badge-amber { background: var(--amber-l); color: var(--amber); }
.badge-blue { background: var(--blue-l); color: var(--blue); }
.badge-purple { background: var(--purple-l); color: var(--purple); }
/* ── キャンペーン効果 ── */
.campaign-effect {
display: grid; grid-template-columns: 1fr auto 1fr;
gap: 0; align-items: center; margin-bottom: 16px;
}
.ce-box {
padding: 16px; text-align: center;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg2);
}
.ce-arrow {
display: flex; flex-direction: column; align-items: center;
padding: 0 12px;
}
.ce-arrow-icon { font-size: 24px; }
.ce-change {
font-family: 'JetBrains Mono', monospace; font-weight: 600;
font-size: 20px;
}
.ce-label { font-size: 10px; color: var(--text2); margin-bottom: 6px; }
.ce-val { font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 600; color: var(--text0); }
/* ── 仕入れ提案テーブル ── */
.purchase-grid {
display: grid; grid-template-columns: repeat(7, 1fr);
gap: 4px; margin-top: 12px;
}
.pg-header {
text-align: center; font-size: 10px; color: var(--text2);
padding: 4px; font-family: 'JetBrains Mono', monospace;
}
.pg-cell {
padding: 8px 4px; text-align: center; border-radius: 4px;
font-size: 10px; cursor: default;
transition: transform 0.1s;
}
.pg-cell:hover { transform: scale(1.05); }
/* ── 空状態 ── */
.empty {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 260px;
color: var(--text3); gap: 10px; text-align: center;
}
.empty-icon { font-size: 44px; opacity: 0.4; }
.empty-title { font-size: 14px; color: var(--text2); }
.empty-sub { font-size: 12px; }
/* ── ローディング ── */
#loading {
display: none; position: fixed; inset: 0;
background: rgba(18,16,14,0.85); backdrop-filter: blur(4px);
z-index: 999; align-items: center; justify-content: center;
flex-direction: column; gap: 14px;
}
#loading.show { display: flex; }
.loader-ring {
width: 38px; height: 38px;
border: 3px solid var(--border); border-top-color: var(--amber);
border-radius: 50%; animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#loading-msg { font-size: 12px; color: var(--text2); }
/* ── フィルター行 ── */
.filter-row {
display: flex; gap: 8px; flex-wrap: wrap;
align-items: center; margin-bottom: 14px;
padding: 10px 14px; background: var(--bg2);
border: 1px solid var(--border); border-radius: var(--radius);
}
.filter-row label { font-size: 11px; color: var(--text2); white-space: nowrap; }
.filter-row select, .filter-row input[type=text] { flex: 0 0 auto; width: auto; }
/* ── 欠品リスクゲージ ── */
.risk-bar-wrap { height: 8px; background: var(--bg3); border-radius: 4px; overflow: hidden; }
.risk-bar { height: 100%; border-radius: 4px; transition: width 0.8s; }
@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="loader-ring"></div>
<div id="loading-msg">分析中...</div>
</div>
<!-- ══ ヘッダー ══ -->
<header>
<div class="logo">MISE</div>
<div class="logo-sub">小さなお店の販売分析</div>
<div class="header-right">
<div id="store-name-display" style="color:var(--text3)">データ未読み込み</div>
<div class="header-kpi" id="header-kpi" style="display:none">
<div class="hkpi">
<div class="hkpi-label">総売上</div>
<div class="hkpi-val" id="hkpi-sales">─</div>
</div>
<div class="hkpi">
<div class="hkpi-label">取引数</div>
<div class="hkpi-val" id="hkpi-tx">─</div>
</div>
<div class="hkpi">
<div class="hkpi-label">客単価</div>
<div class="hkpi-val" id="hkpi-avg">─</div>
</div>
</div>
</div>
</header>
<div class="layout">
<!-- ══ サイドパネル ══ -->
<div class="side">
<!-- 店舗情報 -->
<div class="side-sec">
<div class="side-label">店舗設定</div>
<div class="field">
<label>店舗名</label>
<input type="text" id="storeName" placeholder="例: さくら食堂" value="">
</div>
<div class="field">
<label>業種</label>
<select id="storeType">
<option value="food">飲食・カフェ</option>
<option value="retail">小売・雑貨</option>
<option value="grocery">食品・青果</option>
<option value="salon">美容・サロン</option>
<option value="other">その他</option>
</select>
</div>
</div>
<!-- データ読み込み -->
<div class="side-sec">
<div class="side-label">データ読み込み</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" onchange="loadCSV(this)">
<div style="font-size:22px;opacity:0.6;margin-bottom:6px">📂</div>
<div style="font-weight:500">CSVをドロップ / クリックで選択</div>
<div style="font-size:10px;margin-top:6px;color:var(--text3);line-height:1.7">
対応形式:<br>
日時, 商品名, 数量, 金額<br>
※ Square・Airレジ・手入力Excel可
</div>
</div>
<div style="margin-top:8px;display:flex;gap:6px">
<button class="btn btn-outline btn-sm" onclick="showFormatHelp()" style="flex:1">書式ガイド</button>
<button class="btn btn-outline btn-sm" onclick="loadSample('food')" style="flex:1">サンプル</button>
</div>
</div>
<!-- 列マッピング -->
<div class="side-sec" id="col-map-sec" style="display:none">
<div class="side-label">列の対応設定</div>
<div id="col-map-fields"></div>
<button class="btn btn-amber" onclick="applyColMap()" style="margin-top:6px">適用して分析</button>
</div>
<!-- 期間フィルター -->
<div class="side-sec">
<div class="side-label">分析期間</div>
<div class="field">
<label>開始日</label>
<input type="date" id="filterStart">
</div>
<div class="field">
<label>終了日</label>
<input type="date" id="filterEnd">
</div>
<button class="btn btn-outline" onclick="applyFilter()" style="margin-top:2px">絞り込む</button>
</div>
<!-- キャンペーン登録 -->
<div class="side-sec">
<div class="side-label">キャンペーン登録</div>
<div class="field">
<label>名称</label>
<input type="text" id="cpName" placeholder="例: 春のセール">
</div>
<div class="field">
<label>開始日</label>
<input type="date" id="cpStart">
</div>
<div class="field">
<label>終了日</label>
<input type="date" id="cpEnd">
</div>
<button class="btn btn-amber" onclick="addCampaign()" style="margin-top:2px">登録</button>
<div class="campaign-list" id="campaign-list" style="margin-top:8px"></div>
</div>
<!-- ログ -->
<div class="side-sec">
<div class="side-label">ログ</div>
<div id="log"></div>
</div>
</div><!-- /side -->
<!-- ══ メインエリア ══ -->
<div class="main">
<!-- バナー -->
<div class="banner hidden" id="banner">
<div class="banner-state">
<div class="state-ring" id="state-ring">─</div>
<div class="state-label" id="state-label">─</div>
</div>
<div class="banner-msg">
<div class="banner-headline" id="banner-headline">データを読み込んでください</div>
<div class="banner-detail" id="banner-detail"></div>
</div>
<div class="bkpi" id="bkpi-1" style="display:none">
<div class="bkpi-label">先週比</div>
<div class="bkpi-val" id="bkpi-wow">─</div>
<div class="bkpi-sub">週次売上</div>
</div>
<div class="bkpi" id="bkpi-2" style="display:none">
<div class="bkpi-label">最多商品</div>
<div class="bkpi-val" style="font-size:13px" id="bkpi-top">─</div>
<div class="bkpi-sub">売上数量 1位</div>
</div>
<div class="bkpi" id="bkpi-3" style="display:none">
<div class="bkpi-label">ピーク時間帯</div>
<div class="bkpi-val" id="bkpi-peak">─</div>
<div class="bkpi-sub">最売上時間</div>
</div>
</div><!-- /banner -->
<!-- タブバー -->
<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="tab-badge" id="advice-badge" style="display:none">0</span></div>
</div>
<!-- ── タブ: 売上の流れ ── -->
<div class="tab-content active" id="tab-overview">
<div class="empty" id="empty-overview">
<div class="empty-icon">📊</div>
<div class="empty-title">CSVを読み込んでください</div>
<div class="empty-sub">「サンプル」ボタンで動作を確認できます</div>
</div>
<div id="overview-content" style="display:none">
<div class="filter-row">
<label>集計単位</label>
<select id="overview-unit" onchange="buildOverview()">
<option value="day">日次</option>
<option value="week">週次</option>
<option value="month" selected>月次</option>
</select>
<label style="margin-left:8px">商品</label>
<select id="overview-product" onchange="buildOverview()" style="max-width:140px">
<option value="all">すべて</option>
</select>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">売上推移</div>
</div>
<div class="card-sub" id="overview-sub">─</div>
<canvas id="chart-overview" class="tall"></canvas>
</div>
<div class="g2">
<div class="card">
<div class="card-title">曜日別 平均売上</div>
<div class="card-sub">何曜日が稼ぎ頭か</div>
<canvas id="chart-dow"></canvas>
</div>
<div class="card">
<div class="card-title">月別 平均売上</div>
<div class="card-sub">季節による波を確認</div>
<canvas id="chart-month"></canvas>
</div>
</div>
</div>
</div>
<!-- ── タブ: 時間帯分析 ── -->
<div class="tab-content" id="tab-heatmap">
<div class="empty" id="empty-heatmap">
<div class="empty-icon">🕐</div>
<div class="empty-title">時間帯データが必要です</div>
<div class="empty-sub">「日時」列(時分を含む)のあるCSVを読み込んでください</div>
</div>
<div id="heatmap-content" style="display:none">
<div class="filter-row">
<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:8px">商品</label>
<select id="hm-product" onchange="buildHeatmap()" style="max-width:140px">
<option value="all">すべて</option>
</select>
</div>
<div class="card">
<div class="card-title">曜日×時間帯 ヒートマップ</div>
<div class="card-sub">色が濃いほど売上が多い時間帯。仕入れタイミングや人員配置の目安に。</div>
<div class="heatmap-wrap" id="heatmap-grid"></div>
</div>
<div class="g2">
<div class="card">
<div class="card-title">時間帯別 合計</div>
<div class="card-sub">ピーク時間はここ</div>
<canvas id="chart-hour"></canvas>
</div>
<div class="card">
<div class="card-title">時間帯別 商品トップ3</div>
<div class="card-sub">時間帯ごとに何が売れているか</div>
<div id="hour-top3" style="margin-top:8px"></div>
</div>
</div>
</div>
</div>
<!-- ── タブ: 商品分析 ── -->
<div class="tab-content" id="tab-products">
<div class="empty" id="empty-products">
<div class="empty-icon">📦</div>
<div class="empty-title">商品データが必要です</div>
<div class="empty-sub">「商品名」列のあるCSVを読み込んでください</div>
</div>
<div id="products-content" style="display:none">
<div class="filter-row">
<label>並び順</label>
<select id="prod-sort" onchange="buildProducts()">
<option value="amount">売上金額順</option>
<option value="qty">販売数量順</option>
<option value="freq">購入頻度順</option>
</select>
<label style="margin-left:8px">カテゴリ</label>
<select id="prod-cat" onchange="buildProducts()" style="max-width:130px">
<option value="all">すべて</option>
</select>
<label style="margin-left:8px">表示件数</label>
<select id="prod-limit" 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-title">商品ランキング</div>
<div class="card-sub" id="prod-sub">売上への貢献度を確認できます</div>
<div style="overflow-x:auto">
<table class="rank-table" id="prod-table">
<thead><tr>
<th style="width:36px">順位</th>
<th>商品名</th>
<th>カテゴリ</th>
<th>売上金額</th>
<th style="min-width:100px">構成比</th>
<th>数量</th>
<th>取引数</th>
<th>平均単価</th>
<th>傾向</th>
</tr></thead>
<tbody id="prod-tbody"></tbody>
</table>
</div>
</div>
<div class="g2">
<div class="card">
<div class="card-title">カテゴリ別 売上構成</div>
<div class="card-sub">どのカテゴリが主力か</div>
<canvas id="chart-cat-pie"></canvas>
</div>
<div class="card">
<div class="card-title">上位10商品 月別推移</div>
<div class="card-sub">主力商品の動きを追う</div>
<canvas id="chart-top-trend"></canvas>
</div>
</div>
</div>
</div>
<!-- ── タブ: キャンペーン効果 ── -->
<div class="tab-content" id="tab-campaign">
<div class="empty" id="empty-campaign">
<div class="empty-icon">🎯</div>
<div class="empty-title">キャンペーンを登録してください</div>
<div class="empty-sub">左パネルから名称・開始日・終了日を登録すると<br>自動で効果測定します</div>
</div>
<div id="campaign-content" style="display:none">
<div id="campaign-cards"></div>
<div class="card" style="margin-top:4px">
<div class="card-title">キャンペーン期間 売上比較チャート</div>
<div class="card-sub">キャンペーン前(同期間)・期間中・後 の売上推移</div>
<canvas id="chart-campaign" class="tall"></canvas>
</div>
</div>
</div>
<!-- ── タブ: 仕入れ提案 ── -->
<div class="tab-content" id="tab-purchase">
<div class="empty" id="empty-purchase">
<div class="empty-icon">🛒</div>
<div class="empty-title">データを読み込んでください</div>
<div class="empty-sub">過去の販売データから仕入れのタイミングと量を提案します</div>
</div>
<div id="purchase-content" style="display:none">
<div class="g2">
<div class="card">
<div class="card-title">🗓️ 曜日別 仕入れ提案</div>
<div class="card-sub">売上の多い日の前日に仕入れを増やす目安です</div>
<div class="purchase-grid" id="purchase-dow-grid"></div>
<div style="margin-top:12px" id="purchase-dow-advice"></div>
</div>
<div class="card">
<div class="card-title">🕐 時間帯別 品切れリスク</div>
<div class="card-sub">需要が急増する時間帯に在庫が足りなくなるリスク</div>
<div id="stockout-list" style="margin-top:8px;display:flex;flex-direction:column;gap:8px"></div>
</div>
</div>
<div class="card">
<div class="card-title">📦 商品別 仕入れ優先度</div>
<div class="card-sub">売れ行きの速さ × 売上への貢献度から仕入れ優先度を算出しています</div>
<div style="overflow-x:auto;margin-top:8px">
<table class="rank-table" id="purchase-table">
<thead><tr>
<th>商品名</th>
<th>平均日販</th>
<th>週別変動</th>
<th>ピーク曜日</th>
<th>仕入れ優先度</th>
<th>コメント</th>
</tr></thead>
<tbody id="purchase-tbody"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-title">📅 季節・月別 仕入れカレンダー</div>
<div class="card-sub">月ごとの売上傾向から、仕入れを増やすべき時期がわかります</div>
<canvas id="chart-purchase-season"></canvas>
<div id="purchase-season-advice" style="margin-top:12px;display:flex;flex-direction:column;gap:6px"></div>
</div>
</div>
</div>
<!-- ── タブ: アドバイス ── -->
<div class="tab-content" id="tab-advice">
<div class="empty" id="empty-advice">
<div class="empty-icon">💡</div>
<div class="empty-title">データを読み込むと自動で気づきが表示されます</div>
</div>
<div id="advice-content" style="display:none">
<div class="card" style="margin-bottom:16px">
<div class="card-title">📋 総合サマリー</div>
<div class="card-sub" id="advice-summary"></div>
</div>
<div class="advice-list" id="advice-list"></div>
</div>
</div>
</div><!-- /main -->
</div><!-- /layout -->
<script>
/* ══════════════════════════════════════════════════════
MISE — データ処理・分析エンジン
══════════════════════════════════════════════════════ */
// ── グローバル状態 ──────────────────────────────────
let allRows = []; // { datetime, product, category, qty, amount, campaign }
let filteredRows = [];
let campaigns = []; // { name, start, end, color }
let colMap = {}; // { datetime:n, product:n, category:n, qty:n, amount:n }
let csvHeaders = [];
let charts = {};
const CAMPAIGN_COLORS = ['#e8a230','#5cb85a','#4a9fd4','#a07cd4','#e05050','#40b8a0'];
const DOW_JP = ['日','月','火','水','木','金','土'];
const MON_JP = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
// ── ユーティリティ ─────────────────────────────────
const log = (msg, type='info') => {
const el = document.getElementById('log');
const d = document.createElement('div');
d.className = type; d.textContent = '> ' + msg;
el.appendChild(d); el.scrollTop = el.scrollHeight;
};
const fmtY = n => n == null || isNaN(n) ? '─' : '¥' + Math.round(n).toLocaleString();
const fmtN = (n,d=0) => n == null || isNaN(n) ? '─' : n.toFixed(d).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const fmtP = n => (n >= 0 ? '+' : '') + n.toFixed(1) + '%';
const avg = arr => arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : 0;
const sum = arr => arr.reduce((a,b)=>a+b,0);
const std = arr => { const m=avg(arr); return Math.sqrt(arr.reduce((a,b)=>a+(b-m)**2,0)/(arr.length||1)); };
const destroyChart = id => { if(charts[id]){charts[id].destroy();delete charts[id];} };
// Chart.js グローバルデフォルト
const COPTS = {
responsive:true, maintainAspectRatio:true,
animation:{ duration:500 },
plugins:{
legend:{ labels:{ color:'#8a7a66', font:{family:"'IBM Plex Sans JP'",size:10}, boxWidth:12 } },
tooltip:{
backgroundColor:'rgba(18,16,14,0.95)', borderColor:'#3a342a', borderWidth:1,
titleColor:'#f0e8d8', bodyColor:'#c8b99a',
titleFont:{family:"'JetBrains Mono'",size:10}, bodyFont:{family:"'JetBrains Mono'",size:10},
padding:10,
}
},
scales:{
x:{ ticks:{color:'#5a4e40',font:{family:"'JetBrains Mono'",size:9},maxTicksLimit:14},
grid:{color:'rgba(58,52,42,0.5)'}, border:{color:'#3a342a'} },
y:{ ticks:{color:'#5a4e40',font:{family:"'JetBrains Mono'",size:9}},
grid:{color:'rgba(58,52,42,0.5)'}, border:{color:'#3a342a'} }
}
};
function copts(extra={}){ return JSON.parse(JSON.stringify({...COPTS,...extra})); }
// ── CSV 読み込み ───────────────────────────────────
function onDragOver(e){ e.preventDefault(); document.getElementById('dropZone').classList.add('drag'); }
function onDrop(e){
e.preventDefault(); document.getElementById('dropZone').classList.remove('drag');
if(e.dataTransfer.files[0]) parseCSVFile(e.dataTransfer.files[0]);
}
function loadCSV(input){ if(input.files[0]) parseCSVFile(input.files[0]); }
function parseCSVFile(file){
const r = new FileReader();
r.onload = e => {
try{ parseCSVText(e.target.result, file.name.replace(/\.csv$/i,'')); }
catch(err){ log('読み込みエラー: '+err.message,'err'); }
};
r.readAsText(file,'UTF-8');
}
function parseCSVText(text, name=''){
const lines = text.trim().split(/\r?\n/).filter(l=>l.trim());
if(lines.length < 2) throw new Error('データが少なすぎます(2行以上必要)');
// ヘッダー自動検出
const first = lines[0].split(',').map(c=>c.trim().replace(/^["']|["']$/g,''));
const isHeader = first.some(c => /[^\d\-\/: .]/.test(c));
csvHeaders = isHeader ? first : first.map((_,i)=>`列${i+1}`);
const startRow = isHeader ? 1 : 0;
// 列の自動マッピング
colMap = autoMapColumns(csvHeaders);
log(`列検出: ${JSON.stringify(colMap)}`, 'info');
// マッピングが不完全なら設定UIを表示
if(colMap.datetime < 0 && colMap.amount < 0){
showColMapUI(csvHeaders, lines.slice(startRow,startRow+3));
return;
}
// データパース
const raw = [];
for(let i = startRow; i < lines.length; i++){
const cells = splitCSVLine(lines[i]);
const row = parseRow(cells, colMap);
if(row) raw.push(row);
}
if(raw.length === 0) throw new Error('有効なデータ行がありません');
raw.sort((a,b)=>(a.datetime||'').localeCompare(b.datetime||''));
allRows = raw;
filteredRows = [...raw];
// ストア名
const sn = document.getElementById('storeName').value || name || 'お店のデータ';
document.getElementById('storeName').value = sn;
// フィルター日付初期化
const dates = raw.filter(r=>r.date).map(r=>r.date).sort();
if(dates.length){
document.getElementById('filterStart').value = dates[0];
document.getElementById('filterEnd').value = dates[dates.length-1];
}
// 商品プルダウン更新
updateProductSelects();
updateCategorySelect();
log(`読み込み完了: ${raw.length}件 (${dates[0]} ~ ${dates[dates.length-1]})`, 'ok');
runAll();
}
function splitCSVLine(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 autoMapColumns(headers){
const map = { datetime:-1, product:-1, category:-1, qty:-1, amount:-1 };
headers.forEach((h,i)=>{
const l = h.toLowerCase();
if(map.datetime<0 && /日時|日付|time|date|timestamp|売上日/i.test(l)) map.datetime=i;
if(map.product<0 && /商品|品名|item|product|メニュー|menu/i.test(l)) map.product=i;
if(map.category<0 && /カテゴリ|category|分類|種別|genre/i.test(l)) map.category=i;
if(map.qty<0 && /数量|個数|qty|quantity|点数|枚数|杯数/i.test(l)) map.qty=i;
if(map.amount<0 && /金額|売上|amount|price|revenue|合計|小計|税込|total/i.test(l)) map.amount=i;
});
return map;
}
function parseRow(cells, m){
try{
const dtStr = m.datetime>=0 ? cells[m.datetime] : '';
const dt = parseDateTime(dtStr);
const amtRaw = m.amount>=0 ? cells[m.amount] : null;
const amt = amtRaw != null ? parseFloat(amtRaw.replace(/[¥,\s]/g,'')) : null;
if(amt == null || isNaN(amt)) return null;
const qtyRaw = m.qty>=0 ? cells[m.qty] : null;
const qty = qtyRaw != null ? parseFloat(qtyRaw) : 1;
return {
datetime: dt.full,
date: dt.date,
hour: dt.hour,
dow: dt.dow,
month: dt.month,
year: dt.year,
product: m.product>=0 ? (cells[m.product]||'不明') : '不明',
category: m.category>=0 ? (cells[m.category]||'未分類') : '未分類',
qty: isNaN(qty) ? 1 : qty,
amount: amt,
};
}catch(e){ return null; }
}
function parseDateTime(s){
if(!s) return { full:'', date:'', hour:null, dow:null, month:null, year:null };
// 複数フォーマット対応
s = s.replace(/\//g,'-').trim();
const patterns = [
/^(\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 patterns){
const m = s.match(p);
if(m){
let y,mo,d,h=null,mi=null;
if(p===patterns[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 && p!==patterns[3]) h=parseInt(m[4]);
const date = new Date(y, mo-1, d, h||0, 0);
return {
full: s, date: `${y}-${String(mo).padStart(2,'0')}-${String(d).padStart(2,'0')}`,
hour: h, dow: date.getDay(), month: mo, year: y
};
}
}
return { full:s, date:'', hour:null, dow:null, month:null, year:null };
}
// ── 列マッピングUI ─────────────────────────────────
function showColMapUI(headers, sampleLines){
const sec = document.getElementById('col-map-sec');
const flds = document.getElementById('col-map-fields');
sec.style.display='block';
const roles = [
{key:'datetime',label:'日時'},
{key:'product', label:'商品名'},
{key:'category',label:'カテゴリ'},
{key:'qty', label:'数量'},
{key:'amount', label:'金額'},
];
flds.innerHTML = roles.map(r=>`
<div class="field">
<label>${r.label}</label>
<select id="cm-${r.key}">
<option value="-1">(なし)</option>
${headers.map((h,i)=>`<option value="${i}" ${colMap[r.key]==i?'selected':''}>${h}</option>`).join('')}
</select>
</div>
`).join('');
}
function applyColMap(){
const keys = ['datetime','product','category','qty','amount'];
keys.forEach(k=>{
const el = document.getElementById('cm-'+k);
if(el) colMap[k]=parseInt(el.value);
});
document.getElementById('col-map-sec').style.display='none';
// 再パース
if(allRows.length) runAll();
else log('CSVを再読み込みしてください','warn');
}
// ── フィルター ─────────────────────────────────────
function applyFilter(){
const s = document.getElementById('filterStart').value;
const e = document.getElementById('filterEnd').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 updateProductSelects(){
const prods = [...new Set(filteredRows.map(r=>r.product))].sort();
['overview-product','hm-product'].forEach(id=>{
const sel = document.getElementById(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 updateCategorySelect(){
const cats = [...new Set(filteredRows.map(r=>r.category))].sort();
const sel = document.getElementById('prod-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 addCampaign(){
const name = document.getElementById('cpName').value.trim();
const start = document.getElementById('cpStart').value;
const end = document.getElementById('cpEnd').value;
if(!name||!start||!end){ log('名称・開始日・終了日を入力してください','warn'); return; }
if(start > end){ log('開始日は終了日より前にしてください','warn'); return; }
campaigns.push({ name, start, end, color: CAMPAIGN_COLORS[campaigns.length % CAMPAIGN_COLORS.length] });
document.getElementById('cpName').value='';
renderCampaignList();
if(allRows.length) buildCampaign();
log(`キャンペーン登録: ${name}`, 'ok');
}
function renderCampaignList(){
const list = document.getElementById('campaign-list');
if(!campaigns.length){ list.innerHTML=''; return; }
list.innerHTML = campaigns.map((c,i)=>`
<div class="campaign-item">
<div class="campaign-dot" style="background:${c.color}"></div>
<div class="campaign-name">${c.name}</div>
<div class="campaign-dates">${c.start}〜${c.end}</div>
<div class="campaign-del" onclick="deleteCampaign(${i})">✕</div>
</div>
`).join('');
}
function deleteCampaign(i){
campaigns.splice(i,1);
renderCampaignList();
if(allRows.length) buildCampaign();
}
// ── ヘッダーKPI更新 ────────────────────────────────
function updateHeader(){
const rows = filteredRows;
const sn = document.getElementById('storeName').value || 'お店';
document.getElementById('store-name-display').textContent = sn;
document.getElementById('header-kpi').style.display='flex';
const totalAmt = sum(rows.map(r=>r.amount));
const totalQty = sum(rows.map(r=>r.qty));
// 取引数: 同じ日時を1取引とカウント
const txSet = new Set(rows.map(r=>r.datetime||r.date));
const txCnt = txSet.size || rows.length;
document.getElementById('hkpi-sales').textContent = fmtY(totalAmt);
document.getElementById('hkpi-tx').textContent = txCnt.toLocaleString()+'件';
document.getElementById('hkpi-avg').textContent = fmtY(totalAmt/txCnt);
}
// ── バナー更新 ─────────────────────────────────────
function updateBanner(){
const rows = filteredRows;
document.getElementById('banner').classList.remove('hidden');
// 直近の週と前週を比較
const dates = [...new Set(rows.map(r=>r.date))].sort();
const lastDate = dates[dates.length-1];
const last7 = dates.slice(-7);
const 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,headline,detail;
if(wow>8){ state='good';emoji='📈'; headline='売上は好調です'; detail=`先週比 ${fmtP(wow)} の上昇。この調子を維持しましょう。`; }
else if(wow>-5){state='warn';emoji='➡️'; headline='売上は横ばい推移'; detail=`先週比 ${fmtP(wow)}。大きな変化はありません。`; }
else{ state='bad'; emoji='📉'; headline='売上がやや下落しています'; detail=`先週比 ${fmtP(wow)}。仕入れや品揃えを見直すタイミングかもしれません。`; }
document.getElementById('state-ring').className = 'state-ring '+state;
document.getElementById('state-ring').textContent = emoji;
document.getElementById('state-label').textContent = {good:'好調',warn:'横ばい',bad:'注意'}[state];
document.getElementById('banner-headline').textContent = headline;
document.getElementById('banner-detail').textContent = detail;
// バナーKPI
const wowEl = document.getElementById('bkpi-wow');
wowEl.textContent = fmtP(wow);
wowEl.className = 'bkpi-val ' + (wow>=0?'up':'down');
const byProd = {};
rows.forEach(r=>{ byProd[r.product]=(byProd[r.product]||0)+r.qty; });
const topProd = Object.entries(byProd).sort((a,b)=>b[1]-a[1])[0];
document.getElementById('bkpi-top').textContent = topProd ? topProd[0] : '─';
const hourSales = {};
rows.filter(r=>r.hour!=null).forEach(r=>{ hourSales[r.hour]=(hourSales[r.hour]||0)+r.amount; });
const peakH = Object.entries(hourSales).sort((a,b)=>b[1]-a[1])[0];
document.getElementById('bkpi-peak').textContent = peakH ? peakH[0]+'時台' : '─';
['bkpi-1','bkpi-2','bkpi-3'].forEach(id=>document.getElementById(id).style.display='flex');
}
// ── サンプルデータ ─────────────────────────────────
function loadSample(type){
const rows = generateSample(type);
allRows = rows; filteredRows = [...rows];
document.getElementById('storeName').value = 'さくら食堂(サンプル)';
const dates = rows.map(r=>r.date).sort();
document.getElementById('filterStart').value = dates[0];
document.getElementById('filterEnd').value = dates[dates.length-1];
updateProductSelects(); updateCategorySelect();
log(`サンプルデータ読み込み: ${rows.length}件`, 'ok');
// サンプルキャンペーン
campaigns = [
{ name:'春のランチ割引', start:'2024-03-01', end:'2024-03-31', color:'#e8a230' },
{ name:'夏メニューフェア', start:'2024-07-15', end:'2024-08-15', color:'#5cb85a' },
];
renderCampaignList();
runAll();
}
function generateSample(type){
const prods = [
{name:'日替わりランチ', cat:'ランチ', baseQty:8, price:850, peak:[11,12,13]},
{name:'コーヒー', cat:'ドリンク', baseQty:15, price:380, peak:[8,9,10,14,15]},
{name:'カフェラテ', cat:'ドリンク', baseQty:10, price:450, peak:[9,10,11,15]},
{name:'アイスコーヒー', cat:'ドリンク', baseQty:12, price:400, peak:[12,13,14,15]},
{name:'ケーキセット', cat:'スイーツ', baseQty:6, price:750, peak:[14,15,16]},
{name:'モーニングセット',cat:'モーニング',baseQty:10, price:680, peak:[7,8,9]},
{name:'パスタ', cat:'ランチ', baseQty:5, price:950, peak:[12,13]},
{name:'サンドウィッチ', cat:'ランチ', baseQty:4, price:620, peak:[11,12,13]},
{name:'プリン', cat:'スイーツ', baseQty:7, price:320, peak:[14,15,16,17]},
{name:'紅茶', cat:'ドリンク', baseQty:5, price:350, peak:[14,15,16]},
];
const season=[0.85,0.82,0.95,1.0,1.05,1.0,1.15,1.18,1.0,0.98,1.05,1.25];
const dowFactor=[0.7,0.9,0.95,0.95,1.0,1.3,1.2];
const rows=[];
const start=new Date('2024-01-01'), end=new Date('2024-12-31');
for(let d=new Date(start);d<=end;d.setDate(d.getDate()+1)){
const dow=d.getDay(), mo=d.getMonth();
const ds=d.toISOString().slice(0,10);
const isCp1=(ds>='2024-03-01'&&ds<='2024-03-31');
const isCp2=(ds>='2024-07-15'&&ds<='2024-08-15');
const cpBoost=isCp1?1.25:isCp2?1.18:1.0;
prods.forEach(p=>{
const hours = 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];
hours.forEach(h=>{
const isPeak=p.peak.includes(h);
const baseCnt=p.baseQty*(isPeak?1.6:0.3)*season[mo]*dowFactor[dow]*cpBoost;
const cnt=Math.max(0,Math.round(baseCnt*(0.7+Math.random()*0.6)));
if(cnt<=0) return;
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:dow, month:mo+1, year:2024,
product:p.name, category:p.cat,
qty:1, amount:p.price*(0.95+Math.random()*0.1)
});
}
});
});
}
return rows.sort((a,b)=>a.datetime.localeCompare(b.datetime));
}
// ── タブ切り替え ────────────────────────────────────
function switchTab(name, el){
document.getElementById('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');
document.getElementById('tab-'+name).classList.add('active');
}
// ── 書式ガイド ─────────────────────────────────────
function showFormatHelp(){
alert(
'【対応CSVフォーマット】\n\n' +
'■ 最小構成(2列)\n日付,金額\n2024-01-15,1500\n\n' +
'■ 標準(4列)\n日時,商品名,数量,金額\n2024-01-15 12:30,コーヒー,2,760\n\n' +
'■ 詳細(5列)\n日時,商品名,カテゴリ,数量,金額\n2024-01-15 12:30,コーヒー,ドリンク,2,760\n\n' +
'■ 対応ツール\nSquare: 「売上」→「レポート」→CSV書き出し\nAirレジ: 「データ管理」→「売上データ」→CSV\nExcel: そのまま名前を付けて保存→CSV形式'
);
}
// ══════════════════════════════════════════════════════
// 分析エンジン
// ══════════════════════════════════════════════════════
function runAll(full=true){
document.getElementById('loading').classList.add('show');
document.getElementById('loading-msg').textContent='分析中...';
setTimeout(()=>{
try{
updateHeader();
updateBanner();
if(full){
buildOverview();
buildHeatmap();
buildProducts();
buildCampaign();
buildPurchase();
buildAdvice();
}
showContents();
}catch(e){
log('分析エラー: '+e.message,'err');
console.error(e);
}
document.getElementById('loading').classList.remove('show');
},60);
}
function showContents(){
const hasTime = filteredRows.some(r=>r.hour!=null);
const hasProd = filteredRows.some(r=>r.product && r.product!=='不明');
setContent('overview', true);
setContent('heatmap', hasTime);
setContent('products', hasProd);
setContent('campaign', campaigns.length>0);
setContent('purchase', true);
setContent('advice', true);
}
function setContent(tab, show){
document.getElementById('empty-'+tab).style.display = show?'none':'flex';
document.getElementById(tab+'-content').style.display = show?'block':'none';
}
// ─── 売上の流れ ────────────────────────────────────
function buildOverview(){
const unit = document.getElementById('overview-unit').value;
const prod = document.getElementById('overview-product').value;
let rows = filteredRows;
if(prod!=='all') rows = rows.filter(r=>r.product===prod);
// 集計
const byPeriod={};
rows.forEach(r=>{
let key;
if(unit==='day') key=r.date;
else if(unit==='week'){
const d=new Date(r.date);
d.setDate(d.getDate()-d.getDay());
key=d.toISOString().slice(0,10);
} else {
key=r.date.slice(0,7);
}
if(!byPeriod[key]) byPeriod[key]={amount:0,qty:0};
byPeriod[key].amount+=r.amount;
byPeriod[key].qty+=r.qty;
});
const labels=Object.keys(byPeriod).sort();
const amounts=labels.map(k=>byPeriod[k].amount);
// 移動平均
const maN=Math.min(Math.max(3,Math.floor(labels.length/6)),8);
const maVals=amounts.map((_,i)=>i<maN-1?null:avg(amounts.slice(i-maN+1,i+1)));
// トレンドテキスト
const recentN=Math.min(4,Math.floor(labels.length/3));
const recentAmt=avg(amounts.slice(-recentN));
const olderAmt=avg(amounts.slice(-recentN*2,-recentN))||recentAmt;
const trend=(recentAmt-olderAmt)/olderAmt*100;
document.getElementById('overview-sub').textContent=
`集計期間 ${labels[0]} ~ ${labels[labels.length-1]} ` +
`直近傾向: ${Math.abs(trend)<2?'横ばい':trend>0?`上昇(+${fmtN(trend,1)}%)`:`下落(${fmtN(trend,1)}%)`}`;
// メインチャート
destroyChart('overview');
const ctx=document.getElementById('chart-overview').getContext('2d');
const grad=ctx.createLinearGradient(0,0,0,280);
grad.addColorStop(0,'rgba(232,162,48,0.2)'); grad.addColorStop(1,'rgba(232,162,48,0.01)');
// キャンペーン期間アノテーション用バックグラウンド(疑似)
const cpAnnotations=campaigns.map(c=>({
label:c.name, start:c.start, end:c.end, color:c.color
}));
charts['overview']=new Chart(ctx,{
type:'line',
data:{
labels,
datasets:[
{ label:'売上金額', data:amounts,
borderColor:'#e8a230', borderWidth:2,
backgroundColor:grad, fill:true, tension:0.3,
pointRadius:amounts.length>60?0:3, pointBackgroundColor:'#e8a230' },
{ label:`${maN}期移動平均`, data:maVals,
borderColor:'#c8b99a', borderWidth:1.5, borderDash:[5,4],
pointRadius:0, fill:false, tension:0.3 }
]
},
options:{
...copts(),
plugins:{
...COPTS.plugins,
tooltip:{ ...COPTS.plugins.tooltip,
callbacks:{ label:ctx=>ctx.dataset.label+': '+fmtY(ctx.raw) }
}
}
}
});
// 曜日別
const dowAmt=[0,0,0,0,0,0,0], dowCnt=[0,0,0,0,0,0,0];
rows.forEach(r=>{ if(r.dow!=null){ dowAmt[r.dow]+=r.amount; dowCnt[r.dow]++; } });
const dowAvg=dowAmt.map((a,i)=>dowCnt[i]?a/dowCnt[i]:0);
destroyChart('dow');
const ctxDow=document.getElementById('chart-dow').getContext('2d');
const maxDow=Math.max(...dowAvg);
charts['dow']=new Chart(ctxDow,{
type:'bar',
data:{ labels:DOW_JP,
datasets:[{ label:'平均売上',data:dowAvg,
backgroundColor:dowAvg.map(v=>v>=maxDow*0.9?'rgba(232,162,48,0.85)':v>=maxDow*0.7?'rgba(232,162,48,0.55)':'rgba(90,78,64,0.5)'),
borderRadius:4,borderWidth:0 }]
},
options:{...copts(),plugins:{...COPTS.plugins,tooltip:{...COPTS.plugins.tooltip,
callbacks:{label:c=>fmtY(c.raw)}}}}
});
// 月別
const monAmt=new Array(12).fill(0), monCnt=new Array(12).fill(0);
rows.forEach(r=>{ if(r.month){ monAmt[r.month-1]+=r.amount; monCnt[r.month-1]++; } });
const monAvg=monAmt.map((a,i)=>monCnt[i]?a/monCnt[i]:0);
const totalMonAvg=avg(monAvg.filter(v=>v>0));
destroyChart('month');
const ctxMon=document.getElementById('chart-month').getContext('2d');
charts['month']=new Chart(ctxMon,{
type:'bar',
data:{ labels:MON_JP,
datasets:[{ label:'月平均売上', data:monAvg,
backgroundColor:monAvg.map(v=>v>totalMonAvg*1.1?'rgba(92,184,90,0.75)':v<totalMonAvg*0.9?'rgba(224,80,80,0.6)':'rgba(74,159,212,0.6)'),
borderRadius:4,borderWidth:0 }]
},
options:{...copts(),plugins:{...COPTS.plugins,tooltip:{...COPTS.plugins.tooltip,
callbacks:{label:c=>fmtY(c.raw)}}}}
});
}
// ─── 時間帯ヒートマップ ─────────────────────────────
function buildHeatmap(){
const metric=document.getElementById('hm-metric').value;
const prod =document.getElementById('hm-product').value;
let rows=filteredRows.filter(r=>r.hour!=null);
if(prod!=='all') rows=rows.filter(r=>r.product===prod);
if(!rows.length) return;
// dow×hour の集計
const matrix=Array.from({length:7},()=>new Array(24).fill(0));
const matCnt=Array.from({length:7},()=>new Array(24).fill(0));
rows.forEach(r=>{
if(r.dow==null||r.hour==null) return;
if(metric==='amount') matrix[r.dow][r.hour]+=r.amount;
else if(metric==='qty') matrix[r.dow][r.hour]+=r.qty;
else matCnt[r.dow][r.hour]++;
});
const data=metric==='tx'?matCnt:matrix;
// 正規化
const allVals=data.flat().filter(v=>v>0);
const maxVal=Math.max(...allVals,1);
// ヒートマップ描画
const grid=document.getElementById('heatmap-grid');
let html='<div class="heatmap">';
// ヘッダー行
html+='<div class="hm-cell hm-header"></div>';
for(let h=0;h<24;h++) html+=`<div class="hm-cell hm-header">${h}</div>`;
// データ行
for(let d=0;d<7;d++){
html+=`<div class="hm-cell hm-label">${DOW_JP[d]}</div>`;
for(let h=0;h<24;h++){
const v=data[d][h];
const ratio=maxVal>0?v/maxVal:0;
const intensity=Math.round(ratio*100);
// 色: 0=暗い bg3、MAX=amber
const r=Math.round(30+intensity*2.0);
const g=Math.round(26+intensity*1.3);
const bk=Math.round(18+intensity*0.3);
const alpha=ratio<0.05?0.1:0.15+ratio*0.75;
const bg=ratio<0.05?'var(--bg3)':`rgba(${r+180},${g+100},${bk+10},${alpha+0.5})`;
const label=v>0?(metric==='amount'?`¥${Math.round(v/1000)}k`:Math.round(v)):'';
html+=`<div class="hm-cell" style="background:${bg}" title="${DOW_JP[d]} ${h}時: ${metric==='amount'?fmtY(v):fmtN(v)}">${ratio>0.3?label:''}</div>`;
}
}
html+='</div>';
grid.innerHTML=html;
// 時間帯別棒グラフ
const hourAmt=new Array(24).fill(0);
rows.forEach(r=>{ hourAmt[r.hour]+=r.amount; });
const peakH=hourAmt.indexOf(Math.max(...hourAmt));
destroyChart('hour');
const ctxH=document.getElementById('chart-hour').getContext('2d');
charts['hour']=new Chart(ctxH,{
type:'bar',
data:{ labels:[...Array(24).keys()].map(h=>h+'時'),
datasets:[{ label:'売上金額', data:hourAmt,
backgroundColor:hourAmt.map((_,i)=>i===peakH?'rgba(232,162,48,0.9)':'rgba(90,78,64,0.5)'),
borderRadius:3,borderWidth:0 }]
},
options:{...copts(),plugins:{...COPTS.plugins,tooltip:{...COPTS.plugins.tooltip,
callbacks:{label:c=>fmtY(c.raw)}}}}
});
// 時間帯別 商品トップ3
const hourProd={};
rows.forEach(r=>{
if(!hourProd[r.hour]) hourProd[r.hour]={};
hourProd[r.hour][r.product]=(hourProd[r.hour][r.product]||0)+r.amount;
});
const peakHours=[...Array(24).keys()].sort((a,b)=>hourAmt[b]-hourAmt[a]).slice(0,5);
const h3El=document.getElementById('hour-top3');
h3El.innerHTML=peakHours.map(h=>{
const ps=Object.entries(hourProd[h]||{}).sort((a,b)=>b[1]-a[1]).slice(0,3);
return `<div style="margin-bottom:10px">
<div style="font-size:11px;color:var(--amber);font-family:'JetBrains Mono',monospace;margin-bottom:3px">▶ ${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)">${fmtY(p[1])}</span>
</div>`).join('')}
</div>`;
}).join('');
}
// ─── 商品分析 ──────────────────────────────────────
function buildProducts(){
const sort =document.getElementById('prod-sort').value;
const cat =document.getElementById('prod-cat').value;
const limit=parseInt(document.getElementById('prod-limit').value);
let rows=filteredRows;
if(cat!=='all') rows=rows.filter(r=>r.category===cat);
// 商品集計
const byProd={};
rows.forEach(r=>{
if(!byProd[r.product]) byProd[r.product]={amount:0,qty:0,tx:0,cat:r.category,dates:new Set()};
byProd[r.product].amount+=r.amount;
byProd[r.product].qty+=r.qty;
byProd[r.product].tx++;
byProd[r.product].dates.add(r.date);
});
const total=sum(Object.values(byProd).map(v=>v.amount));
let prods=Object.entries(byProd).map(([name,v])=>({
name, ...v,
avgPrice: v.qty>0?v.amount/v.qty:0,
freq: v.dates.size,
}));
// ソート
if(sort==='amount') prods.sort((a,b)=>b.amount-a.amount);
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,limit);
const maxAmt=top[0]?.amount||1;
document.getElementById('prod-sub').textContent=
`${prods.length}種類の商品 / 合計売上 ${fmtY(total)}`;
// テーブル
const tbody=document.getElementById('prod-tbody');
tbody.innerHTML=top.map((p,i)=>{
const pct=total>0?p.amount/total*100:0;
const trendColor=i<3?'var(--amber)':i<10?'var(--green)':'var(--text2)';
const badge=i===0?`<span class="badge badge-amber">🥇 1位</span>`:
i===1?`<span class="badge badge-blue">🥈 2位</span>`:
i===2?`<span class="badge badge-green">🥉 3位</span>`:'';
return `<tr>
<td><span class="rank-num" style="color:${trendColor}">${i+1}</span></td>
<td style="font-weight:500">${p.name} ${badge}</td>
<td><span class="badge badge-purple">${p.cat}</span></td>
<td style="font-family:'JetBrains Mono',monospace;font-weight:600">${fmtY(p.amount)}</td>
<td>
<div style="display:flex;align-items:center;gap:8px">
<div class="rank-bar-wrap" style="flex:1">
<div class="rank-bar" style="width:${pct/Math.max(...top.map(x=>x.amount/total*100))*100}%;background:var(--amber)"></div>
</div>
<span style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);width:36px;text-align:right">${fmtN(pct,1)}%</span>
</div>
</td>
<td style="font-family:'JetBrains Mono',monospace">${fmtN(p.qty)}</td>
<td style="font-family:'JetBrains Mono',monospace;color:var(--text2)">${p.tx}</td>
<td style="font-family:'JetBrains Mono',monospace">${fmtY(p.avgPrice)}</td>
<td>${i<Math.ceil(limit*0.3)?'<span class="badge badge-green">主力</span>':i<Math.ceil(limit*0.6)?'<span class="badge badge-blue">安定</span>':'<span class="badge badge-amber">補助</span>'}</td>
</tr>`;
}).join('');
// カテゴリ円グラフ
const byCat={};
rows.forEach(r=>{ byCat[r.category]=(byCat[r.category]||0)+r.amount; });
const catLabels=Object.keys(byCat); const catVals=catLabels.map(k=>byCat[k]);
const catColors=['#e8a230','#5cb85a','#4a9fd4','#a07cd4','#e05050','#40b8a0','#e88a30','#30b8a0'];
destroyChart('cat-pie');
const ctxPie=document.getElementById('chart-cat-pie').getContext('2d');
charts['cat-pie']=new Chart(ctxPie,{
type:'doughnut',
data:{ labels:catLabels,
datasets:[{ data:catVals, backgroundColor:catColors.slice(0,catLabels.length),
borderColor:'var(--bg1)', borderWidth:2 }]
},
options:{
responsive:true,maintainAspectRatio:true,
plugins:{ legend:{...COPTS.plugins.legend,position:'right'},
tooltip:{...COPTS.plugins.tooltip,callbacks:{label:c=>`${c.label}: ${fmtY(c.raw)} (${fmtN(c.raw/sum(catVals)*100,1)}%)`}} }
}
});
// 上位5商品 月別推移
const top5=prods.slice(0,5);
const months=[...new Set(rows.map(r=>r.date.slice(0,7)))].sort();
const top5Datasets=top5.map((p,i)=>{
const data=months.map(mo=>sum(rows.filter(r=>r.product===p.name&&r.date.startsWith(mo)).map(r=>r.amount)));
return { label:p.name, data, borderColor:catColors[i], borderWidth:1.5,
pointRadius:2, fill:false, tension:0.3 };
});
destroyChart('top-trend');
const ctxTT=document.getElementById('chart-top-trend').getContext('2d');
charts['top-trend']=new Chart(ctxTT,{
type:'line',
data:{ labels:months, datasets:top5Datasets },
options:{...copts(),plugins:{...COPTS.plugins,tooltip:{...COPTS.plugins.tooltip,
callbacks:{label:c=>c.dataset.label+': '+fmtY(c.raw)}}}}
});
}
// ─── キャンペーン効果 ──────────────────────────────
function buildCampaign(){
if(!campaigns.length){
setContent('campaign',false); return;
}
setContent('campaign',true);
const cards=document.getElementById('campaign-cards');
cards.innerHTML='';
campaigns.forEach((cp,ci)=>{
const cpRows=filteredRows.filter(r=>r.date>=cp.start&&r.date<=cp.end);
if(!cpRows.length){ return; }
// 期間の長さ(日数)
const s=new Date(cp.start), e=new Date(cp.end);
const days=Math.round((e-s)/86400000)+1;
// 前の同期間(直前)
const preEnd=new Date(s); preEnd.setDate(preEnd.getDate()-1);
const preStart=new Date(preEnd); preStart.setDate(preStart.getDate()-days+1);
const preS=preStart.toISOString().slice(0,10);
const preE=preEnd.toISOString().slice(0,10);
const preRows=filteredRows.filter(r=>r.date>=preS&&r.date<=preE);
// 後の同期間
const postStart=new Date(e); postStart.setDate(postStart.getDate()+1);
const postEnd=new Date(postStart); postEnd.setDate(postEnd.getDate()+days-1);
const postS=postStart.toISOString().slice(0,10);
const postE=postEnd.toISOString().slice(0,10);
const postRows=filteredRows.filter(r=>r.date>=postS&&r.date<=postE);
const cpAmt = sum(cpRows.map(r=>r.amount));
const preAmt = sum(preRows.map(r=>r.amount));
const postAmt= sum(postRows.map(r=>r.amount));
const cpQty = sum(cpRows.map(r=>r.qty));
const preQty = sum(preRows.map(r=>r.qty));
const vsPrePct = preAmt>0?(cpAmt-preAmt)/preAmt*100:0;
const vsPostPct= postAmt>0&&postRows.length?(cpAmt-postAmt)/postAmt*100:null;
// 最も伸びた商品
const byProdCp={}, byProdPre={};
cpRows.forEach(r=>{ byProdCp[r.product]=(byProdCp[r.product]||0)+r.amount; });
preRows.forEach(r=>{ byProdPre[r.product]=(byProdPre[r.product]||0)+r.amount; });
const prodLifts=Object.keys(byProdCp).map(p=>({
name:p,
lift: byProdPre[p]>0?(byProdCp[p]-byProdPre[p])/byProdPre[p]*100:100
})).sort((a,b)=>b.lift-a.lift).slice(0,3);
const isPositive=vsPrePct>=0;
const card=document.createElement('div');
card.className='card';
card.innerHTML=`
<div class="card-header">
<div class="card-title">🎯 ${cp.name}</div>
<span class="badge ${isPositive?'badge-green':'badge-red'}">${isPositive?'効果あり':'効果薄'}</span>
</div>
<div class="card-sub">${cp.start} ~ ${cp.end}(${days}日間)</div>
<div class="campaign-effect">
<div class="ce-box">
<div class="ce-label">キャンペーン前(同期間)</div>
<div class="ce-val">${fmtY(preAmt)}</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px">数量: ${fmtN(preQty)}点</div>
</div>
<div class="ce-arrow">
<div class="ce-arrow-icon">${isPositive?'→':'→'}</div>
<div class="ce-change" style="color:${isPositive?'var(--green)':'var(--red)'}">
${fmtP(vsPrePct)}
</div>
</div>
<div class="ce-box" style="border-color:${cp.color};box-shadow:0 0 0 1px ${cp.color}20">
<div class="ce-label">キャンペーン期間中</div>
<div class="ce-val" style="color:${cp.color}">${fmtY(cpAmt)}</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px">数量: ${fmtN(cpQty)}点</div>
</div>
</div>
${vsPostPct!=null?`<div style="font-size:12px;color:var(--text2);margin-bottom:12px">
※ キャンペーン後(同期間)との比較: <span style="color:${vsPostPct>=0?'var(--green)':'var(--red)'}">
${fmtP(vsPostPct)}</span>(期間後の反動を確認)
</div>`:''}
${prodLifts.length?`<div style="margin-top:8px">
<div style="font-size:11px;color:var(--text2);margin-bottom:6px">▶ 最も伸びた商品</div>
${prodLifts.map((p,i)=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 0;border-bottom:1px solid var(--border)">
<span style="font-size:12px">${i+1}. ${p.name}</span>
<span class="badge ${p.lift>=0?'badge-green':'badge-red'}">${fmtP(p.lift)}</span>
</div>`).join('')}
</div>`:''}
<div style="margin-top:12px;padding:10px;background:var(--bg2);border-radius:var(--radius);font-size:12px;color:var(--text1);line-height:1.7">
${isPositive
? `✅ このキャンペーンは前回同期間より ${fmtP(vsPrePct)} の売上増加をもたらしました。${prodLifts[0]?`特に「${prodLifts[0].name}」が大きく伸びています。`:''}次回も同時期に実施することを検討してください。`
: `⚠️ このキャンペーン期間の売上は前回同期間より ${fmtP(vsPrePct)} となりました。集客方法や対象商品・割引率の見直しを検討してみましょう。`
}
</div>
`;
cards.appendChild(card);
});
// キャンペーン比較チャート(全体推移+キャンペーン色帯)
const months=[...new Set(filteredRows.map(r=>r.date.slice(0,7)))].sort();
const monthAmt=months.map(m=>sum(filteredRows.filter(r=>r.date.startsWith(m)).map(r=>r.amount)));
destroyChart('campaign');
const ctx=document.getElementById('chart-campaign').getContext('2d');
const grad=ctx.createLinearGradient(0,0,0,300);
grad.addColorStop(0,'rgba(232,162,48,0.25)'); grad.addColorStop(1,'rgba(232,162,48,0.01)');
charts['campaign']=new Chart(ctx,{
type:'line',
data:{
labels:months,
datasets:[
{ label:'月次売上', data:monthAmt,
borderColor:'#e8a230', borderWidth:2,
backgroundColor:grad, fill:true, tension:0.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+'40',
fill:true, tension:0.3, pointRadius:5,
pointBackgroundColor:cp.color,
spanGaps:false
}))
]
},
options:{...copts(),plugins:{...COPTS.plugins,tooltip:{...COPTS.plugins.tooltip,
callbacks:{label:c=>c.raw!=null?c.dataset.label+': '+fmtY(c.raw):''}}}}
});
}
// ─── 仕入れ提案 ────────────────────────────────────
function buildPurchase(){
const rows=filteredRows;
// 曜日別集計
const dowAmt=new Array(7).fill(0), dowCnt=new Array(7).fill(0);
rows.forEach(r=>{ if(r.dow!=null){ dowAmt[r.dow]+=r.amount; dowCnt[r.dow]++; } });
const dowAvg=dowAmt.map((a,i)=>dowCnt[i]?a/dowCnt[i]:0);
const maxDowAvg=Math.max(...dowAvg,1);
// 曜日別グリッド
const pg=document.getElementById('purchase-dow-grid');
pg.innerHTML='<div class="pg-header" style="text-align:left;padding-left:4px">曜日</div>'+
DOW_JP.map(d=>`<div class="pg-header">${d}</div>`).join('')+
'<div class="pg-header" style="text-align:left;padding-left:4px">水準</div>'+
dowAvg.map((a,i)=>{
const r=a/maxDowAvg;
const level=r>0.85?'多':r>0.65?'中':'少';
const bg=r>0.85?'rgba(232,162,48,0.7)':r>0.65?'rgba(92,184,90,0.5)':'rgba(58,52,42,0.7)';
const col=r>0.85?'#12100e':r>0.65?'#f0e8d8':'#8a7a66';
return `<div class="pg-cell" style="background:${bg};color:${col}" title="${DOW_JP[i]}: ${fmtY(a)}">${level}</div>`;
}).join('')+
'<div class="pg-header" style="text-align:left;padding-left:4px">平均売上</div>'+
dowAvg.map(a=>`<div class="pg-cell" style="font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text2)">${fmtY(a)}</div>`).join('');
// 曜日アドバイス
const topDow=dowAvg.map((a,i)=>({i,a})).sort((a,b)=>b.a-a.a);
const top2Dow=topDow.slice(0,2);
const padv=document.getElementById('purchase-dow-advice');
padv.innerHTML=top2Dow.map(({i,a})=>`
<div class="advice info" style="padding:8px 12px">
<div class="advice-icon">🗓️</div>
<div>
<div class="advice-title">${DOW_JP[i]}曜日が売上のピーク(平均 ${fmtY(a)})</div>
<div class="advice-desc">${DOW_JP[(i+6)%7]}曜日(前日)に仕入れを多めに確保しておくと品切れを防げます。</div>
</div>
</div>
`).join('');
// 時間帯別 品切れリスク
const hourAmt=new Array(24).fill(0), hourCnt=new Array(24).fill(0);
rows.filter(r=>r.hour!=null).forEach(r=>{ hourAmt[r.hour]+=r.amount; hourCnt[r.hour]++; });
const hourAvg=hourAmt.map((a,i)=>hourCnt[i]?a/hourCnt[i]:0);
const totalHourAvg=avg(hourAvg.filter(v=>v>0));
const riskHours=hourAvg.map((a,i)=>({h:i,a,risk:a/totalHourAvg}))
.filter(x=>x.risk>1.3&&hourCnt[x.h]>0).sort((a,b)=>b.risk-a.risk).slice(0,5);
const sl=document.getElementById('stockout-list');
sl.innerHTML=riskHours.map(x=>`
<div style="padding:8px 0;border-bottom:1px solid var(--border)">
<div style="display:flex;justify-content:space-between;margin-bottom:5px">
<span style="font-family:'JetBrains Mono',monospace;color:var(--amber)">${x.h}時台</span>
<span class="badge ${x.risk>1.7?'badge-red':'badge-amber'}">${x.risk>1.7?'高リスク':'要注意'}</span>
</div>
<div class="risk-bar-wrap">
<div class="risk-bar" style="width:${Math.min(100,x.risk/2*100)}%;background:${x.risk>1.7?'var(--red)':'var(--amber)'}"></div>
</div>
<div style="font-size:10px;color:var(--text3);margin-top:3px">平均需要の ${fmtN(x.risk*100,0)}% ― この時間帯は在庫を多めに</div>
</div>
`).join('')||'<div style="color:var(--text3);font-size:12px">目立ったリスク時間帯はありません</div>';
// 商品別仕入れ優先度
const byProd={};
rows.forEach(r=>{
if(!byProd[r.product]) byProd[r.product]={amt:0,qty:0,dates:new Set(),dowAmts:new Array(7).fill(0),dowCnts:new Array(7).fill(0)};
byProd[r.product].amt+=r.amount;
byProd[r.product].qty+=r.qty;
byProd[r.product].dates.add(r.date);
if(r.dow!=null){ byProd[r.product].dowAmts[r.dow]+=r.amount; byProd[r.product].dowCnts[r.dow]++; }
});
const allDates=[...new Set(rows.map(r=>r.date))];
const totalAmt=sum(Object.values(byProd).map(v=>v.amt));
const prods=Object.entries(byProd).map(([name,v])=>{
const dailyAvg=v.qty/allDates.length;
const dowAvgs=v.dowAmts.map((a,i)=>v.dowCnts[i]?a/v.dowCnts[i]:0);
const peakDow=dowAvgs.indexOf(Math.max(...dowAvgs));
const volatility=std(dowAvgs.filter(x=>x>0))/avg(dowAvgs.filter(x=>x>0));
const contribution=v.amt/totalAmt;
const priority=contribution*0.6+(dailyAvg>0.5?0.3:0.1)+(volatility>0.4?0.1:0.05);
return {name, dailyAvg, peakDow, volatility, contribution, priority};
}).sort((a,b)=>b.priority-a.priority).slice(0,15);
const pt=document.getElementById('purchase-tbody');
pt.innerHTML=prods.map(p=>`<tr>
<td style="font-weight:500">${p.name}</td>
<td style="font-family:'JetBrains Mono',monospace">${fmtN(p.dailyAvg,1)}点/日</td>
<td><span class="badge ${p.volatility>0.4?'badge-red':p.volatility>0.2?'badge-amber':'badge-green'}">${p.volatility>0.4?'変動大':p.volatility>0.2?'普通':'安定'}</span></td>
<td>${DOW_JP[p.peakDow]}曜</td>
<td>
<div class="risk-bar-wrap" style="margin-bottom:3px">
<div class="risk-bar" style="width:${p.priority/prods[0].priority*100}%;background:var(--amber)"></div>
</div>
<span class="badge ${p.priority>prods[0].priority*0.7?'badge-amber':p.priority>prods[0].priority*0.4?'badge-blue':'badge-green'}">${p.priority>prods[0].priority*0.7?'最優先':p.priority>prods[0].priority*0.4?'優先':'通常'}</span>
</td>
<td style="font-size:11px;color:var(--text2)">${DOW_JP[(p.peakDow+6)%7]}に多めに仕入れ${p.volatility>0.4?'(変動注意)':''}</td>
</tr>`).join('');
// 季節・月別
const monAmt=new Array(12).fill(0), monCnt=new Array(12).fill(0);
rows.forEach(r=>{ if(r.month){ monAmt[r.month-1]+=r.amount; monCnt[r.month-1]++; } });
const monAvg=monAmt.map((a,i)=>monCnt[i]?a/monCnt[i]:0);
const totalMonAvg2=avg(monAvg.filter(v=>v>0));
destroyChart('purchase-season');
const ctxPS=document.getElementById('chart-purchase-season').getContext('2d');
charts['purchase-season']=new Chart(ctxPS,{
type:'bar',
data:{ labels:MON_JP,
datasets:[{ label:'月平均売上', data:monAvg,
backgroundColor:monAvg.map(v=>v>totalMonAvg2*1.15?'rgba(232,162,48,0.85)':v<totalMonAvg2*0.85?'rgba(74,159,212,0.6)':'rgba(90,78,64,0.5)'),
borderRadius:4, borderWidth:0 }]
},
options:{...copts(),plugins:{...COPTS.plugins,tooltip:{...COPTS.plugins.tooltip,
callbacks:{label:c=>fmtY(c.raw)}}}}
});
// 月別アドバイス
const highMonths=monAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>totalMonAvg2*1.12).sort((a,b)=>b.a-a.a);
const lowMonths =monAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>0&&x.a<totalMonAvg2*0.88).sort((a,b)=>a.a-b.a);
const psa=document.getElementById('purchase-season-advice');
const seasonAdvices=[];
if(highMonths.length) seasonAdvices.push({type:'warn',icon:'📈',
title:`${highMonths.map(x=>MON_JP[x.m]).join('・')}は需要が高い時期`,
desc:`平均より${fmtN((highMonths[0].a/totalMonAvg2-1)*100,0)}%以上多く売れます。この時期の1〜2週間前に仕入れ量を増やし、品切れを防ぎましょう。`});
if(lowMonths.length) seasonAdvices.push({type:'info',icon:'📉',
title:`${lowMonths.map(x=>MON_JP[x.m]).join('・')}は売上が落ちる時期`,
desc:`平均より${fmtN((1-lowMonths[0].a/totalMonAvg2)*100,0)}%程度少ない傾向があります。この時期は仕入れを絞って在庫ロスを減らしましょう。`});
psa.innerHTML=seasonAdvices.map(a=>`
<div class="advice ${a.type}">
<div class="advice-icon">${a.icon}</div>
<div><div class="advice-title">${a.title}</div><div class="advice-desc">${a.desc}</div></div>
</div>`).join('');
}
// ─── アドバイス ────────────────────────────────────
function buildAdvice(){
const rows=filteredRows;
const advices=[];
const vals=rows.map(r=>r.amount);
const totalAmt=sum(vals);
const dates=[...new Set(rows.map(r=>r.date))].sort();
const days=dates.length;
// 1. 売上トレンド
const recentN=Math.min(7,Math.floor(days/3));
const recentDates=dates.slice(-recentN);
const olderDates =dates.slice(-recentN*2,-recentN);
const recentAmt=sum(rows.filter(r=>recentDates.includes(r.date)).map(r=>r.amount));
const olderAmt =sum(rows.filter(r=>olderDates.includes(r.date)).map(r=>r.amount));
const trendPct=olderAmt>0?(recentAmt-olderAmt)/olderAmt*100:0;
if(trendPct>10) advices.push({type:'good',icon:'📈',
title:'売上が伸びています',
desc:`直近${recentN}日間の売上は、その前の同期間より ${fmtP(trendPct)} 増加しています。何が効いているかを記録しておきましょう。`});
else if(trendPct<-10) advices.push({type:'bad',icon:'⚠️',
title:'売上が下落傾向にあります',
desc:`直近${recentN}日間の売上は、その前の同期間より ${fmtP(trendPct)} 減少しています。品揃えや接客、競合の動向を確認してください。`});
// 2. ピーク時間帯
const hourAmt=new Array(24).fill(0);
rows.filter(r=>r.hour!=null).forEach(r=>{ hourAmt[r.hour]+=r.amount; });
const peakH=hourAmt.indexOf(Math.max(...hourAmt));
const peakAmt=hourAmt[peakH];
if(peakAmt>0) advices.push({type:'teal',icon:'⏰',
title:`${peakH}時台が最も売れる時間帯です`,
desc:`この時間帯の売上は全体の ${fmtN(peakAmt/totalAmt*100,1)}%。この時間に品切れがないよう、少なくとも1〜2時間前までに補充・仕込みを完了させましょう。`});
// 3. 最も売れる曜日
const dowAmt=new Array(7).fill(0), dowCnt=new Array(7).fill(0);
rows.forEach(r=>{ if(r.dow!=null){ dowAmt[r.dow]+=r.amount; dowCnt[r.dow]++; } });
const dowAvg=dowAmt.map((a,i)=>dowCnt[i]?a/dowCnt[i]:0);
const peakDow=dowAvg.indexOf(Math.max(...dowAvg));
const worstDow=dowAvg.indexOf(Math.min(...dowAvg.filter(v=>v>0)));
advices.push({type:'info',icon:'📅',
title:`${DOW_JP[peakDow]}曜日が売上トップ(平均 ${fmtY(dowAvg[peakDow])})`,
desc:`${DOW_JP[(peakDow+6)%7]}曜日(前日)は補充・仕込みを多めにしておくのが鉄則です。逆に${DOW_JP[worstDow]}曜日(平均 ${fmtY(dowAvg[worstDow])})は仕入れを絞ると廃棄ロスを減らせます。`});
// 4. 上位商品の集中度(パレート)
const byProd={};
rows.forEach(r=>{ byProd[r.product]=(byProd[r.product]||0)+r.amount; });
const prodsSorted=Object.entries(byProd).sort((a,b)=>b[1]-a[1]);
const top20pct=Math.ceil(prodsSorted.length*0.2);
const top20amt=sum(prodsSorted.slice(0,top20pct).map(x=>x[1]));
const top20ratio=top20amt/totalAmt*100;
advices.push({type:'purple',icon:'📦',
title:`上位${top20pct}商品で売上の ${fmtN(top20ratio,0)}% を占めています`,
desc:`「${prodsSorted.slice(0,3).map(x=>x[0]).join('・')}」が特に主力です。この商品たちが品切れにならないよう優先的に在庫を確保しましょう。`});
// 5. 季節的な注意
const monAmt=new Array(12).fill(0), monCnt=new Array(12).fill(0);
rows.forEach(r=>{ if(r.month){ monAmt[r.month-1]+=r.amount; monCnt[r.month-1]++; } });
const monAvg=monAmt.map((a,i)=>monCnt[i]?a/monCnt[i]:0);
const totalMonAvg=avg(monAvg.filter(v=>v>0));
const highMon=monAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>totalMonAvg*1.15).sort((a,b)=>b.a-a.a);
const lowMon =monAvg.map((a,i)=>({m:i,a})).filter(x=>x.a>0&&x.a<totalMonAvg*0.85).sort((a,b)=>a.a-b.a);
if(highMon.length) advices.push({type:'warn',icon:'🌸',
title:`${highMon.map(x=>MON_JP[x.m]).slice(0,3).join('・')}は繁忙期です`,
desc:`これらの月は平均より ${fmtN((highMon[0].a/totalMonAvg-1)*100,0)}% 以上売れる傾向があります。1〜2週間前から仕入れ量を増やし、繁忙期の機会損失を防ぎましょう。`});
if(lowMon.length) advices.push({type:'info',icon:'🍂',
title:`${lowMon.map(x=>MON_JP[x.m]).slice(0,3).join('・')}は閑散期です`,
desc:`売上が平均より ${fmtN((1-lowMon[0].a/totalMonAvg)*100,0)}% 低い傾向があります。この時期はキャンペーンを仕掛けるか、在庫・人員を絞るかを検討しましょう。`});
// 6. キャンペーン効果の振り返り
if(campaigns.length>0){
const cpEffects=campaigns.map(cp=>{
const cpAmt=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 cpDays=Math.round((d2-d1)/86400000)+1;
const preEnd=new Date(d1); preEnd.setDate(preEnd.getDate()-1);
const preStart=new Date(preEnd); preStart.setDate(preStart.getDate()-cpDays+1);
const preAmt=sum(filteredRows.filter(r=>r.date>=preStart.toISOString().slice(0,10)&&r.date<=preEnd.toISOString().slice(0,10)).map(r=>r.amount));
return {name:cp.name, lift:preAmt>0?(cpAmt-preAmt)/preAmt*100:0};
}).sort((a,b)=>b.lift-a.lift);
const best=cpEffects[0];
if(best && best.lift>5) advices.push({type:'good',icon:'🎯',
title:`「${best.name}」キャンペーンが効果的でした`,
desc:`前回同期間比で ${fmtP(best.lift)} の売上増。このキャンペーンのタイミング・内容は成功パターンとして記録しておきましょう。`});
else if(best && best.lift<-5) advices.push({type:'bad',icon:'🎯',
title:`キャンペーンの効果が出ていません`,
desc:`「${best.name}」は前回同期間比 ${fmtP(best.lift)} にとどまりました。割引率・告知方法・対象商品の見直しをお勧めします。`});
}
// 7. 客単価のアドバイス
const txSet={};
rows.forEach(r=>{ const k=r.datetime||r.date; if(!txSet[k]) txSet[k]=0; txSet[k]+=r.amount; });
const txAmts=Object.values(txSet);
const avgTx=avg(txAmts);
if(avgTx < 800) advices.push({type:'warn',icon:'💰',
title:`客単価 ${fmtY(avgTx)} — アップセルの余地があります`,
desc:'「もう1品いかがですか?」の声がけや、セット・コンボメニューの導入で客単価を高める施策が効果的です。'});
else if(avgTx > 3000) advices.push({type:'good',icon:'💰',
title:`客単価 ${fmtY(avgTx)} — 高単価路線が機能しています`,
desc:'客単価が高い水準にあります。品質・サービスへの投資を継続しながら、新規客獲得にも力を入れましょう。'});
// サマリー
document.getElementById('advice-summary').textContent=
`分析期間: ${dates[0]} ~ ${dates[dates.length-1]}(${days}日間) ` +
`総売上: ${fmtY(totalAmt)} 取引件数: ${txAmts.length}件 ` +
`客単価: ${fmtY(avgTx)} 商品種類: ${prodsSorted.length}種`;
// 描画
const list=document.getElementById('advice-list');
list.innerHTML=advices.map(a=>`
<div class="advice ${a.type}">
<div class="advice-icon">${a.icon}</div>
<div><div class="advice-title">${a.title}</div><div class="advice-desc">${a.desc}</div></div>
</div>`).join('');
// バッジ更新
const badge=document.getElementById('advice-badge');
badge.textContent=advices.length;
badge.style.display='inline-flex';
document.getElementById('empty-advice').style.display='none';
document.getElementById('advice-content').style.display='block';
}
// ── 初期ログ ────────────────────────────────────────
log('サンプルボタンでデモを確認できます', 'info');
log('CSVフォーマット: 日時,商品名,数量,金額', 'info');
</script>
</body>
</html>

コメント