お店の経営ツール「Mise Manager」
【更新履歴】
・2026/2/20 バージョン1.0公開。
・2026/2/20 バージョン1.1公開。(原料在庫を追加)
・ダウンロードされる方はこちら。↓
https://drive.google.com/drive/folders/1YXZyfO9mndQnj0MMkmiM1nsdGJEGvlGB?ths=true
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MISE Manager 1.1 — 在庫・経理管理</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=M+PLUS+Rounded+1c:wght@400;500;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root{
--bg:#f4f7ff;--bg2:#eaf0ff;--surface:#ffffff;
--navy:#1a2f6e;--blue:#3b6ef5;--blue-l:#dce8ff;
--mint:#22c55e;--mint-l:#dcfce7;
--coral:#ef4444;--coral-l:#fee2e2;
--amber:#f59e0b;--amber-l:#fef3c7;
--purple:#8b5cf6;--purple-l:#ede9fe;
--sky:#0ea5e9;--sky-l:#e0f2fe;
--text:#1e293b;--text2:#64748b;--text3:#94a3b8;
--border:#e2e8f0;--shadow:0 4px 20px rgba(30,60,140,.08);
--r:16px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
html,body{height:100%;overflow:hidden;}
body{background:var(--bg);color:var(--text);font-family:'M PLUS Rounded 1c',sans-serif;font-size:14px;display:flex;flex-direction:column;}
/* ── Header ── */
.mgr-header{background:linear-gradient(135deg,var(--navy) 0%,#2d4a9e 100%);color:#fff;padding:0 24px;height:56px;display:flex;align-items:center;gap:16px;flex-shrink:0;box-shadow:0 3px 16px rgba(26,47,110,.35);}
.mgr-logo{font-family:'Nunito',sans-serif;font-size:20px;font-weight:900;letter-spacing:.03em;display:flex;align-items:center;gap:8px;}
.mgr-logo .badge{font-size:11px;font-weight:700;background:var(--amber);color:var(--navy);padding:2px 8px;border-radius:20px;}
.hdr-clock{font-family:'Nunito',monospace;font-size:15px;font-weight:800;opacity:.8;margin-left:auto;}
.hdr-actions{display:flex;gap:8px;}
.hdr-btn{background:rgba(255,255,255,.15);border:1.5px solid rgba(255,255,255,.3);color:#fff;border-radius:10px;padding:6px 14px;font-size:12px;font-family:inherit;font-weight:700;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:5px;}
.hdr-btn:hover{background:rgba(255,255,255,.28);}
.hdr-btn.primary{background:var(--blue);border-color:var(--blue);}
/* ── Layout ── */
.mgr-wrap{display:flex;flex:1;overflow:hidden;}
.sidebar{width:200px;background:var(--surface);border-right:2px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden;}
.main-content{flex:1;overflow-y:auto;overflow-x:hidden;}
.main-content::-webkit-scrollbar{width:5px;}
.main-content::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
/* ── Sidebar Nav ── */
.nav-section{padding:12px 10px 4px;font-size:10px;font-weight:800;color:var(--text3);letter-spacing:.08em;}
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 14px;margin:2px 8px;border-radius:12px;cursor:pointer;font-size:13px;font-weight:700;color:var(--text2);transition:all .15s;border:none;background:none;width:calc(100% - 16px);text-align:left;font-family:inherit;}
.nav-item:hover{background:var(--bg2);color:var(--navy);}
.nav-item.active{background:var(--blue-l);color:var(--blue);}
.nav-item .nav-icon{font-size:18px;flex-shrink:0;}
.nav-item .nav-badge{margin-left:auto;background:var(--coral);color:#fff;font-size:10px;font-weight:800;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center;}
.nav-footer{margin-top:auto;padding:12px;border-top:2px solid var(--border);}
.nav-store{font-size:11px;font-weight:700;color:var(--text3);text-align:center;}
/* ── Panel ── */
.panel{display:none;padding:24px;min-height:100%;}
.panel.active{display:block;}
.panel-title{font-size:20px;font-weight:800;color:var(--navy);margin-bottom:4px;display:flex;align-items:center;gap:8px;}
.panel-sub{font-size:13px;color:var(--text2);margin-bottom:20px;}
/* ── KPI Cards ── */
.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px;}
.kpi-card{background:var(--surface);border-radius:var(--r);padding:18px 20px;box-shadow:var(--shadow);border-bottom:4px solid transparent;position:relative;overflow:hidden;}
.kpi-card::before{content:'';position:absolute;top:-20px;right:-20px;width:80px;height:80px;border-radius:50%;opacity:.08;}
.kpi-card.kpi-blue{border-color:var(--blue);}
.kpi-card.kpi-blue::before{background:var(--blue);}
.kpi-card.kpi-mint{border-color:var(--mint);}
.kpi-card.kpi-mint::before{background:var(--mint);}
.kpi-card.kpi-amber{border-color:var(--amber);}
.kpi-card.kpi-amber::before{background:var(--amber);}
.kpi-card.kpi-coral{border-color:var(--coral);}
.kpi-card.kpi-coral::before{background:var(--coral);}
.kpi-card.kpi-purple{border-color:var(--purple);}
.kpi-card.kpi-purple::before{background:var(--purple);}
.kpi-label{font-size:11px;font-weight:800;color:var(--text2);margin-bottom:6px;letter-spacing:.04em;}
.kpi-val{font-family:'Nunito',sans-serif;font-size:26px;font-weight:900;color:var(--text);line-height:1;}
.kpi-val small{font-size:14px;font-weight:700;}
.kpi-diff{font-size:11px;font-weight:700;margin-top:5px;display:flex;align-items:center;gap:3px;}
.kpi-diff.up{color:var(--mint);}
.kpi-diff.down{color:var(--coral);}
.kpi-diff.flat{color:var(--text3);}
.kpi-icon{position:absolute;top:14px;right:16px;font-size:24px;opacity:.6;}
/* ── Section Card ── */
.s-card{background:var(--surface);border-radius:var(--r);padding:20px;box-shadow:var(--shadow);margin-bottom:16px;}
.s-card-title{font-size:14px;font-weight:800;color:var(--navy);margin-bottom:14px;display:flex;align-items:center;gap:6px;justify-content:space-between;}
.s-card-title span{font-size:12px;font-weight:700;color:var(--text2);}
/* ── Grid layouts ── */
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;}
.grid-auto{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;}
/* ── Charts ── */
.chart-wrap{position:relative;height:240px;}
.chart-wrap.tall{height:320px;}
/* ── Table ── */
.tbl{width:100%;border-collapse:collapse;font-size:13px;}
.tbl th{background:var(--bg2);padding:8px 12px;text-align:left;font-size:11px;font-weight:800;color:var(--text2);border-bottom:2px solid var(--border);}
.tbl td{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:middle;}
.tbl tr:last-child td{border-bottom:none;}
.tbl tr:hover td{background:var(--bg);}
.tbl .num{text-align:right;font-family:'Nunito',sans-serif;font-weight:700;}
.tbl .good{color:var(--mint);}
.tbl .warn{color:var(--amber);}
.tbl .bad{color:var(--coral);}
.tbl-scroll{overflow-x:auto;overflow-y:auto;max-height:320px;}
.tbl-scroll::-webkit-scrollbar{height:4px;width:4px;}
.tbl-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
/* ── Badge/Chip ── */
.chip{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;}
.chip-blue{background:var(--blue-l);color:var(--blue);}
.chip-mint{background:var(--mint-l);color:#16a34a;}
.chip-coral{background:var(--coral-l);color:var(--coral);}
.chip-amber{background:var(--amber-l);color:#92400e;}
.chip-purple{background:var(--purple-l);color:var(--purple);}
.chip-gray{background:var(--bg2);color:var(--text2);}
/* ── Progress bar ── */
.prog-bar{height:8px;background:var(--bg2);border-radius:4px;overflow:hidden;margin-top:4px;}
.prog-fill{height:100%;border-radius:4px;transition:width .4s;}
/* ── Alert ── */
.alert{display:flex;align-items:flex-start;gap:10px;padding:12px 16px;border-radius:12px;margin-bottom:10px;font-size:13px;}
.alert-warn{background:var(--amber-l);border-left:4px solid var(--amber);}
.alert-bad{background:var(--coral-l);border-left:4px solid var(--coral);}
.alert-good{background:var(--mint-l);border-left:4px solid var(--mint);}
.alert-info{background:var(--blue-l);border-left:4px solid var(--blue);}
.alert-icon{font-size:18px;flex-shrink:0;}
.alert-body{flex:1;}
.alert-title{font-weight:800;margin-bottom:2px;}
.alert-text{color:var(--text2);font-size:12px;line-height:1.5;}
/* ── Form controls ── */
.f-row{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;margin-bottom:14px;}
.f-group{display:flex;flex-direction:column;gap:4px;}
.f-group label{font-size:11px;font-weight:800;color:var(--text2);}
.f-input{border:2px solid var(--border);border-radius:10px;padding:8px 12px;font-size:13px;font-family:inherit;font-weight:600;color:var(--text);background:var(--surface);outline:none;transition:border-color .15s;}
.f-input:focus{border-color:var(--blue);}
.f-input[type="number"]{width:120px;}
.f-btn{border:none;border-radius:10px;padding:9px 18px;font-size:13px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .15s;display:flex;align-items:center;gap:6px;white-space:nowrap;}
.f-btn-blue{background:var(--blue);color:#fff;}
.f-btn-blue:hover{background:#2d5ee8;}
.f-btn-mint{background:var(--mint);color:#fff;}
.f-btn-mint:hover{background:#16a34a;}
.f-btn-coral{background:var(--coral);color:#fff;}
.f-btn-coral:hover{background:#dc2626;}
.f-btn-ghost{background:var(--bg2);color:var(--text2);}
.f-btn-ghost:hover{background:var(--border);}
.f-btn-amber{background:var(--amber);color:#fff;}
.f-btn-amber:hover{background:#d97706;}
select.f-input{cursor:pointer;}
/* ── Stock card ── */
.stock-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;}
.stock-card{background:var(--surface);border:2px solid var(--border);border-radius:14px;padding:14px;position:relative;transition:all .15s;}
.stock-card:hover{box-shadow:var(--shadow);border-color:var(--blue-l);}
.stock-card.warn-low{border-color:var(--amber);background:var(--amber-l);}
.stock-card.warn-out{border-color:var(--coral);background:var(--coral-l);}
.stock-icon{font-size:28px;margin-bottom:6px;display:block;}
.stock-name{font-size:13px;font-weight:800;margin-bottom:4px;line-height:1.2;}
.stock-cat{font-size:10px;color:var(--text2);font-weight:700;margin-bottom:8px;}
.stock-qty{font-family:'Nunito',sans-serif;font-size:28px;font-weight:900;line-height:1;}
.stock-qty small{font-size:12px;font-weight:700;color:var(--text2);}
.stock-meta{display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;}
.stock-alert-badge{position:absolute;top:10px;right:10px;font-size:16px;}
.stock-edit-btn{background:var(--bg2);border:none;border-radius:8px;padding:4px 8px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;color:var(--text2);transition:all .15s;}
.stock-edit-btn:hover{background:var(--blue-l);color:var(--blue);}
/* ── P&L table ── */
.pl-section{margin-bottom:16px;}
.pl-row{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-radius:10px;margin-bottom:3px;}
.pl-row.header{background:var(--bg2);font-size:11px;font-weight:800;color:var(--text2);}
.pl-row.item{font-size:13px;}
.pl-row.item:hover{background:var(--bg);}
.pl-row.subtotal{background:var(--bg2);font-weight:800;font-size:13px;}
.pl-row.total{background:var(--navy);color:#fff;font-size:16px;font-weight:800;border-radius:12px;}
.pl-row.total .num{color:var(--amber);}
.pl-row .num{font-family:'Nunito',sans-serif;font-weight:800;}
.pl-row .add-btn{background:var(--blue-l);border:none;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700;cursor:pointer;color:var(--blue);font-family:inherit;}
/* ── Purchase log ── */
.purchase-item{display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--surface);border:2px solid var(--border);border-radius:12px;margin-bottom:8px;transition:all .15s;}
.purchase-item:hover{border-color:var(--blue-l);box-shadow:var(--shadow);}
.purchase-icon{font-size:22px;flex-shrink:0;}
.purchase-info{flex:1;}
.purchase-name{font-size:13px;font-weight:800;}
.purchase-meta{font-size:11px;color:var(--text2);margin-top:2px;}
.purchase-amt{font-family:'Nunito',sans-serif;font-size:16px;font-weight:900;color:var(--blue);}
.purchase-del{border:none;background:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px;transition:color .15s;}
.purchase-del:hover{color:var(--coral);}
/* ── Analysis ── */
.rank-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:10px;margin-bottom:6px;background:var(--bg);}
.rank-no{font-family:'Nunito',sans-serif;font-size:18px;font-weight:900;color:var(--text3);min-width:28px;text-align:center;}
.rank-no.top1{color:var(--amber);}
.rank-no.top2{color:var(--text2);}
.rank-no.top3{color:#cd7f32;}
.rank-bar-wrap{flex:1;}
.rank-name{font-size:13px;font-weight:700;margin-bottom:2px;}
.rank-bar-track{height:6px;background:var(--border);border-radius:4px;overflow:hidden;}
.rank-bar-fill{height:100%;border-radius:4px;background:var(--blue);transition:width .5s;}
.rank-val{font-family:'Nunito',sans-serif;font-size:14px;font-weight:800;color:var(--text);text-align:right;min-width:80px;}
/* ── Period filter ── */
.period-bar{display:flex;gap:6px;margin-bottom:16px;flex-wrap:wrap;align-items:center;}
.period-btn{border:2px solid var(--border);background:var(--surface);border-radius:10px;padding:6px 14px;font-size:12px;font-weight:800;cursor:pointer;font-family:inherit;color:var(--text2);transition:all .15s;}
.period-btn.active{background:var(--navy);border-color:var(--navy);color:#fff;}
.period-btn:hover:not(.active){border-color:var(--blue);color:var(--blue);}
/* ── Toast ── */
.toast{position:fixed;top:70px;left:50%;transform:translateX(-50%);background:var(--navy);color:#fff;padding:10px 22px;border-radius:20px;font-size:13px;font-weight:700;z-index:9999;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;}
.toast.show{opacity:1;}
/* ── Modal ── */
.modal{position:fixed;inset:0;background:rgba(0,20,60,.45);backdrop-filter:blur(4px);z-index:5000;display:flex;align-items:center;justify-content:center;animation:fadeIn .2s;}
.modal.hidden{display:none;}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal-box{background:var(--surface);border-radius:24px;padding:28px;max-width:500px;width:92%;box-shadow:0 20px 60px rgba(0,0,0,.25);animation:slideUp .25s;}
@keyframes slideUp{from{transform:translateY(24px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal-title{font-size:18px;font-weight:800;margin-bottom:16px;color:var(--navy);}
.modal-btns{display:flex;gap:10px;margin-top:20px;}
.modal-btns button{flex:1;border:none;border-radius:12px;padding:11px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .15s;}
.mbtn-cancel{background:var(--bg2);color:var(--text2);}
.mbtn-ok{background:var(--blue);color:#fff;}
/* ── Upload area ── */
.upload-zone{display:block;border:3px dashed var(--border);border-radius:16px;padding:32px;text-align:center;cursor:pointer;transition:all .2s;background:var(--bg);}
.upload-zone:hover,.upload-zone.drag{border-color:var(--blue);background:var(--blue-l);}
.upload-zone input{display:none;}
/* ── Responsive ── */
@media(max-width:900px){
.sidebar{width:56px;}
.nav-item span:not(.nav-icon){display:none;}
.nav-badge{display:none;}
.kpi-grid{grid-template-columns:1fr 1fr;}
.grid-2,.grid-3{grid-template-columns:1fr;}
}
@media(max-width:600px){
.kpi-grid{grid-template-columns:1fr;}
.sidebar{display:none;}
}
/* ── 原料カード ── */
.ingr-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px;}
.ingr-card{background:var(--surface);border:2px solid var(--border);border-radius:14px;padding:14px;position:relative;transition:all .15s;cursor:default;}
.ingr-card:hover{box-shadow:var(--shadow);border-color:var(--blue-l);}
.ingr-card.warn-low{border-color:var(--amber);background:var(--amber-l);}
.ingr-card.warn-out{border-color:var(--coral);background:var(--coral-l);}
.ingr-unit{font-size:10px;font-weight:700;background:var(--bg2);color:var(--text2);border-radius:6px;padding:1px 7px;display:inline-block;margin-bottom:6px;}
.ingr-name{font-size:14px;font-weight:800;margin-bottom:2px;line-height:1.2;}
.ingr-cat{font-size:10px;color:var(--text2);margin-bottom:8px;}
.ingr-qty-wrap{display:flex;align-items:baseline;gap:4px;}
.ingr-qty{font-family:'Nunito',sans-serif;font-size:28px;font-weight:900;color:var(--text);line-height:1;}
.ingr-qty-unit{font-size:13px;color:var(--text2);font-weight:700;}
.ingr-theory{font-size:11px;color:var(--text2);margin-top:2px;}
.ingr-alert{position:absolute;top:10px;right:10px;font-size:16px;}
.ingr-actions{display:flex;gap:6px;margin-top:8px;}
.ingr-act-btn{flex:1;border:none;border-radius:8px;padding:4px 0;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .15s;}
.ingr-act-btn.edit{background:var(--blue-l);color:var(--blue);}
.ingr-act-btn.edit:hover{background:var(--blue);color:#fff;}
.ingr-act-btn.del{background:var(--coral-l);color:var(--coral);}
.ingr-act-btn.del:hover{background:var(--coral);color:#fff;}
/* ── レシピカード ── */
.recipe-list{display:flex;flex-direction:column;gap:8px;}
.recipe-card{background:var(--surface);border:2px solid var(--border);border-radius:12px;overflow:hidden;}
.recipe-head{display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;transition:background .15s;}
.recipe-head:hover{background:var(--bg);}
.recipe-prod-icon{font-size:20px;}
.recipe-prod-name{flex:1;font-size:13px;font-weight:800;}
.recipe-ing-count{font-size:11px;color:var(--text2);background:var(--bg2);border-radius:6px;padding:2px 8px;}
.recipe-toggle{font-size:14px;color:var(--text3);transition:transform .2s;}
.recipe-toggle.open{transform:rotate(180deg);}
.recipe-body{display:none;padding:0 14px 12px;border-top:1px solid var(--border);}
.recipe-body.open{display:block;}
.recipe-ing-row{display:flex;justify-content:space-between;align-items:center;padding:5px 0;border-bottom:1px dotted var(--border);font-size:12px;}
.recipe-ing-row:last-child{border-bottom:none;}
.recipe-del-btn{border:none;background:none;color:var(--text3);cursor:pointer;font-size:14px;padding:0 2px;}
.recipe-del-btn:hover{color:var(--coral);}
/* ── 棚卸しテーブル ── */
.stocktake-row td input{width:90px;border:2px solid var(--border);border-radius:8px;padding:4px 8px;font-size:13px;font-family:inherit;text-align:right;outline:none;}
.stocktake-row td input:focus{border-color:var(--blue);}
.loss-pos{color:var(--coral);font-weight:800;}
.loss-neg{color:var(--mint);font-weight:800;}
/* scrollbar */
::-webkit-scrollbar{width:5px;height:5px;}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
</style>
</head>
<body>
<header class="mgr-header">
<div class="mgr-logo">📊 MISE Manager<span class="badge">経営</span></div>
<div id="hdr-clock" class="hdr-clock"></div>
<div class="hdr-actions">
<button class="hdr-btn" onclick="openImportModal()">📂 CSVを読み込む</button>
<button class="hdr-btn primary" onclick="exportReport()">📤 レポート出力</button>
</div>
</header>
<div class="mgr-wrap">
<nav class="sidebar">
<div class="nav-section">メニュー</div>
<button class="nav-item active" onclick="showPanel('dashboard',this)"><span class="nav-icon">📊</span><span>ダッシュボード</span></button>
<button class="nav-item" onclick="showPanel('stock',this)"><span class="nav-icon">📦</span><span>在庫管理</span><span class="nav-badge" id="nb-stock" style="display:none">0</span></button>
<button class="nav-item" onclick="showPanel('finance',this)"><span class="nav-icon">💰</span><span>収支・経理</span></button>
<button class="nav-item" onclick="showPanel('analysis',this)"><span class="nav-icon">📈</span><span>売上分析</span></button>
<button class="nav-item" onclick="showPanel('purchase',this)"><span class="nav-icon">🗒️</span><span>仕入れ帳</span></button>
<button class="nav-item" onclick="showPanel('settings',this)"><span class="nav-icon">⚙️</span><span>設定</span></button>
<div class="nav-footer">
<div class="nav-store" id="nav-store">店舗未設定</div>
</div>
</nav>
<main class="main-content">
<div id="panel-dashboard" class="panel active">
<div class="panel-title">📊 ダッシュボード</div>
<div class="panel-sub" id="dash-date-label">本日の経営状況をひと目でチェック</div>
<div id="dash-alerts"></div>
<div class="kpi-grid" id="kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">今日の売上</div>
<div class="kpi-val" id="kpi-today-sales">¥—</div>
<div class="kpi-diff flat" id="kpi-today-diff">—</div>
<div class="kpi-icon">💴</div>
</div>
<div class="kpi-card kpi-mint">
<div class="kpi-label">今月の売上</div>
<div class="kpi-val" id="kpi-month-sales">¥—</div>
<div class="kpi-diff flat" id="kpi-month-diff">—</div>
<div class="kpi-icon">📅</div>
</div>
<div class="kpi-card kpi-amber">
<div class="kpi-label">今月の客数</div>
<div class="kpi-val" id="kpi-month-tx"><small></small>—</div>
<div class="kpi-diff flat" id="kpi-month-tx-diff">—</div>
<div class="kpi-icon">👥</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">今月の客単価</div>
<div class="kpi-val" id="kpi-atv">¥—</div>
<div class="kpi-diff flat" id="kpi-atv-diff">—</div>
<div class="kpi-icon">🎯</div>
</div>
</div>
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">📈 直近30日の売上推移 <span>日別</span></div>
<div class="chart-wrap"><canvas id="ch-daily"></canvas></div>
</div>
<div class="s-card">
<div class="s-card-title">🍰 今月のカテゴリ別売上 <span></span></div>
<div class="chart-wrap"><canvas id="ch-cat-pie"></canvas></div>
</div>
</div>
<div class="s-card">
<div class="s-card-title">⚠️ 在庫アラート <span id="stock-alert-count"></span></div>
<div id="dash-stock-alerts">
<div style="color:var(--text3);text-align:center;padding:20px;font-size:13px;">データがありません</div>
</div>
</div>
<div class="s-card">
<div class="s-card-title">🏆 今月のトップ商品(売上金額) <span></span></div>
<div id="dash-top-products"></div>
</div>
</div>
<div id="panel-stock" class="panel">
<div class="panel-title">📦 在庫管理</div>
<div class="panel-sub">商品の販売在庫と、原料・食材の在庫を両方管理できます</div>
<div class="period-bar" id="stock-main-tabs" style="margin-bottom:20px;">
<button class="period-btn active" onclick="showStockMainTab('product',this)">🛍️ 商品在庫</button>
<button class="period-btn" onclick="showStockMainTab('ingredient',this)">🧂 原料在庫</button>
</div>
<div id="stock-product-area">
<div class="grid-2" style="margin-bottom:16px;">
<div class="s-card">
<div class="s-card-title">➕ 在庫を更新</div>
<div class="f-group" style="margin-bottom:10px;">
<label>商品を選択</label>
<select class="f-input" id="stock-prod-sel" style="width:100%;"></select>
</div>
<div class="f-row">
<div class="f-group">
<label>操作種類</label>
<select class="f-input" id="stock-op" style="width:140px;">
<option value="set">在庫数をセット</option>
<option value="add">追加(仕入れ)</option>
<option value="sub">減らす(廃棄など)</option>
</select>
</div>
<div class="f-group">
<label>数量</label>
<input type="number" class="f-input" id="stock-qty" min="0" placeholder="10">
</div>
</div>
<div class="f-row">
<div class="f-group" style="flex:1;">
<label>メモ(任意)</label>
<input class="f-input" id="stock-memo" placeholder="例: 朝仕入れ" style="width:100%;">
</div>
</div>
<button class="f-btn f-btn-blue" style="width:100%" onclick="updateStock()">✅ 在庫を更新する</button>
</div>
<div class="s-card">
<div class="s-card-title">🔔 アラート設定</div>
<div class="f-group" style="margin-bottom:10px;">
<label>商品を選択</label>
<select class="f-input" id="alert-prod-sel" style="width:100%;"></select>
</div>
<div class="f-row">
<div class="f-group">
<label>警告ライン(残りX個以下)</label>
<input type="number" class="f-input" id="alert-warn" min="0" placeholder="5" style="width:110px;">
</div>
<div class="f-group">
<label>危険ライン(残りX個以下)</label>
<input type="number" class="f-input" id="alert-danger" min="0" placeholder="2" style="width:110px;">
</div>
</div>
<button class="f-btn f-btn-amber" style="width:100%" onclick="saveAlertSetting()">💾 アラート設定を保存</button>
</div>
</div>
<div class="f-row" style="margin-bottom:12px;">
<button class="period-btn active" onclick="filterStock('all',this)">すべて</button>
<button class="period-btn" onclick="filterStock('danger',this)">🔴 危険</button>
<button class="period-btn" onclick="filterStock('warn',this)">🟡 警告</button>
<button class="period-btn" onclick="filterStock('ok',this)">🟢 正常</button>
<input class="f-input" id="stock-search" placeholder="🔍 商品名で検索..." onInput="renderStockGrid()" style="margin-left:auto;width:200px;">
</div>
<div class="stock-grid" id="stock-grid"></div>
</div><div id="stock-ingr-area" style="display:none;">
<div class="period-bar" style="margin-bottom:16px;" id="stock-tab-bar">
<button class="period-btn active" onclick="showStockTab('list',this)">📋 在庫一覧</button>
<button class="period-btn" onclick="showStockTab('ingr-edit',this)">➕ 原料登録</button>
<button class="period-btn" onclick="showStockTab('adjust',this)">📥 入出庫</button>
<button class="period-btn" onclick="showStockTab('recipe',this)">📖 レシピ</button>
<button class="period-btn" onclick="showStockTab('stocktake',this)">📊 棚卸し</button>
</div>
<div id="stock-tab-list">
<div class="f-row" style="margin-bottom:12px;">
<button class="period-btn active" id="sf-all" onclick="filterIngr('all',this)">すべて</button>
<button class="period-btn" id="sf-danger" onclick="filterIngr('danger',this)">🔴 危険</button>
<button class="period-btn" id="sf-warn" onclick="filterIngr('warn',this)">🟡 警告</button>
<button class="period-btn" id="sf-ok" onclick="filterIngr('ok',this)">🟢 正常</button>
<input class="f-input" id="ingr-search" placeholder="🔍 原料名で検索..." oninput="renderIngrGrid()" style="margin-left:auto;width:200px;">
</div>
<div class="ingr-grid" id="ingr-grid">
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text3);">
<div style="font-size:40px;margin-bottom:10px;">🧂</div>
<div style="font-size:14px;font-weight:800;">原料がまだ登録されていません</div>
<div style="font-size:12px;margin-top:6px;">「原料登録」タブから追加してください</div>
</div>
</div>
</div>
<div id="stock-tab-ingr-edit" style="display:none;">
<div class="grid-2">
<div class="s-card">
<div class="s-card-title" id="ingr-form-title">➕ 原料を新規登録</div>
<input type="hidden" id="ingr-edit-id">
<div class="f-row">
<div class="f-group" style="flex:1;">
<label>原料名 <span style="color:var(--coral)">*</span></label>
<input class="f-input" id="ingr-name" placeholder="例: コーヒー豆" style="width:100%;">
</div>
<div class="f-group">
<label>カテゴリ</label>
<input class="f-input" id="ingr-cat" placeholder="例: 豆類" list="ingr-cat-list" style="width:130px;">
<datalist id="ingr-cat-list"></datalist>
</div>
</div>
<div class="f-row">
<div class="f-group">
<label>単位 <span style="color:var(--coral)">*</span></label>
<select class="f-input" id="ingr-unit" style="width:110px;">
<option value="g">g(グラム)</option>
<option value="kg">kg(キログラム)</option>
<option value="ml">ml(ミリリットル)</option>
<option value="L">L(リットル)</option>
<option value="個">個</option>
<option value="枚">枚</option>
<option value="本">本</option>
<option value="袋">袋</option>
<option value="パック">パック</option>
<option value="缶">缶</option>
</select>
</div>
<div class="f-group">
<label>現在の在庫量</label>
<input type="number" class="f-input" id="ingr-qty" placeholder="0" min="0" step="0.1">
</div>
<div class="f-group">
<label>仕入れ単価(円/単位)</label>
<input type="number" class="f-input" id="ingr-cost" placeholder="例: 50" min="0" step="0.01">
</div>
</div>
<div class="f-row">
<div class="f-group">
<label>警告ライン(残りX以下)</label>
<input type="number" class="f-input" id="ingr-warn" placeholder="100" min="0" step="0.1">
</div>
<div class="f-group">
<label>危険ライン(残りX以下)</label>
<input type="number" class="f-input" id="ingr-danger" placeholder="30" min="0" step="0.1">
</div>
</div>
<div class="f-group" style="margin-bottom:14px;">
<label>メモ(保存先・規格など)</label>
<input class="f-input" id="ingr-note" placeholder="例: 冷蔵保存、エチオピア産" style="width:100%;">
</div>
<div style="display:flex;gap:10px;">
<button class="f-btn f-btn-blue" style="flex:1;" onclick="saveIngredient()">✅ 保存する</button>
<button class="f-btn f-btn-ghost" onclick="resetIngrForm()">リセット</button>
</div>
<div id="ingr-feedback" style="text-align:center;margin-top:8px;font-size:12px;font-weight:700;color:var(--mint);min-height:18px;"></div>
</div>
<div class="s-card" style="overflow:hidden;">
<div class="s-card-title">📋 登録済み原料</div>
<div class="tbl-scroll" style="max-height:440px;">
<table class="tbl">
<thead><tr><th>原料名</th><th>カテゴリ</th><th>単位</th><th class="num">在庫</th><th class="num">単価</th><th></th></tr></thead>
<tbody id="ingr-list-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="stock-tab-adjust" style="display:none;">
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">📥 在庫を入出庫</div>
<div class="f-group" style="margin-bottom:10px;">
<label>原料を選択 <span style="color:var(--coral)">*</span></label>
<select class="f-input" id="adj-ingr-sel" style="width:100%;" onchange="onAdjIngrChange()"></select>
</div>
<div style="background:var(--bg2);border-radius:10px;padding:10px 14px;margin-bottom:12px;font-size:13px;">
現在の在庫:<b id="adj-current">—</b>
</div>
<div class="f-row">
<div class="f-group">
<label>操作</label>
<select class="f-input" id="adj-op" style="width:150px;">
<option value="add">➕ 追加(仕入れ)</option>
<option value="sub">➖ 減らす(廃棄・使用)</option>
<option value="set">🔄 在庫数をセット</option>
</select>
</div>
<div class="f-group">
<label>数量</label>
<input type="number" class="f-input" id="adj-qty" min="0" step="0.1" placeholder="0" style="width:110px;" oninput="onAdjQtyChange()">
</div>
<div class="f-group" id="adj-cost-wrap">
<label>単価(円)※任意</label>
<input type="number" class="f-input" id="adj-unit-cost" min="0" step="0.01" placeholder="" style="width:110px;">
</div>
</div>
<div style="background:var(--bg2);border-radius:10px;padding:8px 14px;margin-bottom:12px;font-size:13px;">
操作後の在庫:<b id="adj-after">—</b>
</div>
<div class="f-group" style="margin-bottom:12px;">
<label>メモ(任意)</label>
<input class="f-input" id="adj-memo" placeholder="例: 朝仕入れ、スタッフ賄い" style="width:100%;">
</div>
<div class="f-row">
<button class="f-btn f-btn-blue" style="flex:1;" onclick="adjustIngr()">✅ 更新する</button>
</div>
</div>
<div class="s-card">
<div class="s-card-title">📋 入出庫ログ <span id="adj-log-ingr-name"></span></div>
<div class="tbl-scroll" style="max-height:400px;">
<table class="tbl">
<thead><tr><th>日時</th><th>操作</th><th class="num">数量</th><th class="num">在庫後</th><th>メモ</th></tr></thead>
<tbody id="adj-log-tbody"></tbody>
</table>
</div>
</div>
</div>
<div class="s-card" style="margin-top:4px;">
<div class="s-card-title">🤖 売上データから原料消費を自動計算</div>
<div class="alert alert-info" style="margin-bottom:12px;">
<div class="alert-icon">💡</div>
<div class="alert-body">
<div class="alert-title">レシピを登録すると、売上CSVから原料の消費量を自動計算できます</div>
<div class="alert-text">計算結果を確認してから「在庫に反映」を押すと、まとめて在庫が更新されます。</div>
</div>
</div>
<div class="f-row" style="margin-bottom:10px;">
<div class="f-group">
<label>計算対象の期間(開始日)</label>
<input type="date" class="f-input" id="auto-calc-from">
</div>
<div class="f-group">
<label>終了日</label>
<input type="date" class="f-input" id="auto-calc-to">
</div>
<button class="f-btn f-btn-blue" style="margin-top:20px;" onclick="calcAutoConsumption()">🔍 消費量を計算</button>
</div>
<div id="auto-calc-result"></div>
</div>
</div>
<div id="stock-tab-recipe" style="display:none;">
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">📖 レシピを登録</div>
<div class="alert alert-info" style="margin-bottom:12px;">
<div class="alert-icon">💡</div>
<div class="alert-body">
<div class="alert-title">商品1個を作るのに使う原料量を登録</div>
<div class="alert-text">例:カフェラテ1杯 = コーヒー豆15g + 牛乳150ml</div>
</div>
</div>
<div class="f-group" style="margin-bottom:10px;">
<label>対象商品(MISE POSの商品名に合わせる)</label>
<input class="f-input" id="recipe-prod-name" placeholder="例: カフェラテ" list="recipe-prod-list" style="width:100%;">
<datalist id="recipe-prod-list"></datalist>
</div>
<div id="recipe-ingr-rows" style="margin-bottom:10px;display:flex;flex-direction:column;gap:6px;">
</div>
<button class="f-btn f-btn-ghost" style="width:100%;margin-bottom:10px;" onclick="addRecipeIngrRow()">➕ 原料を追加</button>
<button class="f-btn f-btn-mint" style="width:100%;" onclick="saveRecipe()">💾 レシピを保存</button>
<div id="recipe-feedback" style="text-align:center;margin-top:8px;font-size:12px;font-weight:700;color:var(--mint);min-height:18px;"></div>
</div>
<div class="s-card">
<div class="s-card-title">📚 登録済みレシピ一覧</div>
<div class="recipe-list" id="recipe-list">
<div style="text-align:center;color:var(--text3);padding:30px;font-size:13px;">レシピがまだありません</div>
</div>
</div>
</div>
</div>
<div id="stock-tab-stocktake" style="display:none;">
<div class="s-card">
<div class="s-card-title">📊 棚卸し — 実数入力して理論値と比較</div>
<div class="alert alert-warn" style="margin-bottom:12px;">
<div class="alert-icon">⚠️</div>
<div class="alert-body">
<div class="alert-title">実際に数えた在庫量を入力してください</div>
<div class="alert-text">「差異」列がマイナスならロス(廃棄・食べ残し・盗難)、プラスなら棚卸し利益です。「反映」を押すと在庫が実数で更新されます。</div>
</div>
</div>
<div class="f-row" style="margin-bottom:12px;">
<div class="f-group">
<label>棚卸し日</label>
<input type="date" class="f-input" id="stocktake-date">
</div>
<button class="f-btn f-btn-blue" style="margin-top:20px;" onclick="applyStocktake()">✅ 選択した原料の在庫を実数で更新</button>
<button class="f-btn f-btn-ghost" style="margin-top:20px;" onclick="exportStocktakeCSV()">📤 棚卸し表をCSV出力</button>
</div>
<div class="tbl-scroll">
<table class="tbl" id="stocktake-table">
<thead>
<tr>
<th><input type="checkbox" onchange="toggleStocktakeAll(this)" title="全選択"></th>
<th>原料名</th><th>カテゴリ</th><th>単位</th>
<th class="num">理論在庫</th>
<th class="num">実在庫(入力)</th>
<th class="num">差異</th>
<th class="num">ロス金額</th>
</tr>
</thead>
<tbody id="stocktake-tbody"></tbody>
</table>
</div>
</div>
</div>
</div></div><div id="panel-finance" class="panel">
<div class="panel-title">💰 収支・経理</div>
<div class="panel-sub">月ごとの収入・支出・利益を管理します</div>
<div class="f-row" style="margin-bottom:16px;">
<div class="f-group">
<label>表示月</label>
<input type="month" class="f-input" id="finance-month" onchange="renderFinance()">
</div>
<div style="display:flex;gap:6px;margin-top:20px;">
<button class="f-btn f-btn-ghost" onclick="financeMonthOffset(-1)">◀ 前月</button>
<button class="f-btn f-btn-ghost" onclick="financeMonthOffset(1)">翌月 ▶</button>
<button class="f-btn f-btn-blue" onclick="financeMonthOffset(0)">今月</button>
</div>
</div>
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">📋 損益計算書(月次)</div>
<div id="pl-body"></div>
</div>
<div class="s-card">
<div class="s-card-title">📊 収支グラフ(月次推移)</div>
<div class="chart-wrap"><canvas id="ch-finance"></canvas></div>
</div>
</div>
<div class="s-card">
<div class="s-card-title">➕ 経費・支出を登録</div>
<div class="f-row">
<div class="f-group">
<label>日付</label>
<input type="date" class="f-input" id="exp-date">
</div>
<div class="f-group">
<label>科目</label>
<select class="f-input" id="exp-cat" style="width:160px;">
<option value="仕入れ">仕入れ原価</option>
<option value="家賃">家賃・地代</option>
<option value="光熱費">光熱費</option>
<option value="人件費">人件費</option>
<option value="消耗品">消耗品費</option>
<option value="広告">広告・販促費</option>
<option value="通信費">通信費</option>
<option value="その他">その他経費</option>
</select>
</div>
<div class="f-group">
<label>金額(円)</label>
<input type="number" class="f-input" id="exp-amount" placeholder="50000" min="0">
</div>
<div class="f-group" style="flex:1;">
<label>メモ</label>
<input class="f-input" id="exp-memo" placeholder="例: 4月分家賃" style="width:100%;">
</div>
<button class="f-btn f-btn-blue" style="margin-top:20px;" onclick="addExpense()">➕ 追加</button>
</div>
<div class="tbl-scroll" style="max-height:260px;">
<table class="tbl" id="exp-table">
<thead><tr><th>日付</th><th>科目</th><th>内容</th><th class="num">金額</th><th></th></tr></thead>
<tbody id="exp-tbody"></tbody>
</table>
</div>
</div>
<div class="s-card">
<div class="s-card-title">📅 日別売上一覧</div>
<div class="tbl-scroll">
<table class="tbl" id="daily-tbl">
<thead><tr><th>日付</th><th>曜日</th><th class="num">売上</th><th class="num">客数</th><th class="num">客単価</th><th class="num">割引額</th></tr></thead>
<tbody id="daily-tbody"></tbody>
</table>
</div>
</div>
</div>
<div id="panel-analysis" class="panel">
<div class="panel-title">📈 売上分析</div>
<div class="panel-sub">データを多角的に分析して経営改善に活かします</div>
<div class="period-bar">
<span style="font-size:12px;font-weight:700;color:var(--text2);">期間:</span>
<button class="period-btn active" onclick="setAnalysisPeriod('week',this)">直近7日</button>
<button class="period-btn" onclick="setAnalysisPeriod('month',this)">直近30日</button>
<button class="period-btn" onclick="setAnalysisPeriod('quarter',this)">直近90日</button>
<button class="period-btn" onclick="setAnalysisPeriod('all',this)">全期間</button>
<input type="date" class="f-input" id="an-from" style="margin-left:auto;" onchange="setAnalysisPeriod('custom',null)">
<span style="font-size:12px;color:var(--text2);">〜</span>
<input type="date" class="f-input" id="an-to" onchange="setAnalysisPeriod('custom',null)">
</div>
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">🏆 商品別売上ランキング <span id="an-rank-metric-lbl">売上金額</span></div>
<div style="display:flex;gap:6px;margin-bottom:12px;">
<button class="period-btn active" onclick="setRankMetric('amount',this)">金額</button>
<button class="period-btn" onclick="setRankMetric('qty',this)">個数</button>
<button class="period-btn" onclick="setRankMetric('tx',this)">回数</button>
</div>
<div id="product-ranking"></div>
</div>
<div class="s-card">
<div class="s-card-title">🍰 カテゴリ別売上</div>
<div class="chart-wrap"><canvas id="ch-an-cat"></canvas></div>
</div>
</div>
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">🕐 時間帯別売上(棒グラフ)</div>
<div class="chart-wrap"><canvas id="ch-hourly"></canvas></div>
</div>
<div class="s-card">
<div class="s-card-title">📅 曜日別売上(平均)</div>
<div class="chart-wrap"><canvas id="ch-dow"></canvas></div>
</div>
</div>
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">👥 客種別売上構成</div>
<div class="chart-wrap"><canvas id="ch-cust"></canvas></div>
</div>
<div class="s-card">
<div class="s-card-title">🎂 年齢層別売上</div>
<div class="chart-wrap"><canvas id="ch-age"></canvas></div>
</div>
</div>
<div class="s-card">
<div class="s-card-title">📈 月別売上推移</div>
<div class="chart-wrap tall"><canvas id="ch-monthly"></canvas></div>
</div>
</div>
<div id="panel-purchase" class="panel">
<div class="panel-title">🗒️ 仕入れ帳</div>
<div class="panel-sub">仕入れ・入荷の記録と仕入れ先を管理します</div>
<div class="grid-2" style="margin-bottom:16px;">
<div class="s-card">
<div class="s-card-title">➕ 仕入れを記録</div>
<div class="f-row">
<div class="f-group">
<label>日付</label>
<input type="date" class="f-input" id="pur-date">
</div>
<div class="f-group" style="flex:1;">
<label>仕入れ先</label>
<input class="f-input" id="pur-supplier" placeholder="例: 山田商店" list="supplier-list" style="width:100%;">
<datalist id="supplier-list"></datalist>
</div>
</div>
<div class="f-row">
<div class="f-group" style="flex:1;">
<label>仕入れ品目
<span style="font-size:10px;color:var(--text2);">(商品在庫 or 原料在庫に反映されます)</span>
</label>
<select class="f-input" id="pur-prod" style="width:100%;" onchange="onPurProdChange()">
<option value="">(品目を選択)</option>
</select>
</div>
</div>
<div class="f-row">
<div class="f-group">
<label>数量</label>
<input type="number" class="f-input" id="pur-qty" min="1" placeholder="10">
</div>
<div class="f-group">
<label>仕入れ単価(円)</label>
<input type="number" class="f-input" id="pur-unit-price" min="0" placeholder="200">
</div>
<div class="f-group">
<label>合計金額(円)</label>
<input type="number" class="f-input" id="pur-total" min="0" placeholder="2000" readonly style="background:var(--bg2);">
</div>
</div>
<div class="f-group" style="margin-bottom:10px;">
<label>メモ</label>
<input class="f-input" id="pur-memo" placeholder="例: 定期仕入れ" style="width:100%;">
</div>
<button class="f-btn f-btn-mint" style="width:100%;" onclick="addPurchase()">✅ 仕入れを登録</button>
</div>
<div class="s-card">
<div class="s-card-title">📊 仕入れサマリー <span>今月</span></div>
<div id="pur-summary"></div>
<div class="chart-wrap" style="height:200px;margin-top:12px;"><canvas id="ch-pur-cat"></canvas></div>
</div>
</div>
<div class="s-card">
<div class="s-card-title">📋 仕入れ一覧
<div style="display:flex;gap:8px;">
<input type="month" class="f-input" id="pur-filter-month" onchange="renderPurchaseList()" style="font-size:12px;">
<button class="f-btn f-btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="exportPurchaseCSV()">📤 CSV出力</button>
</div>
</div>
<div class="tbl-scroll">
<table class="tbl">
<thead><tr><th>日付</th><th>仕入れ先</th><th>商品</th><th class="num">数量</th><th class="num">単価</th><th class="num">合計</th><th>メモ</th><th></th></tr></thead>
<tbody id="pur-tbody"></tbody>
</table>
</div>
</div>
</div>
<div id="panel-settings" class="panel">
<div class="panel-title">⚙️ 設定</div>
<div class="panel-sub">データの読み込みと基本設定を行います</div>
<div class="grid-2">
<div class="s-card">
<div class="s-card-title">🏪 店舗情報</div>
<div class="f-group" style="margin-bottom:10px;">
<label>店舗名</label>
<input class="f-input" id="set-store" placeholder="例: カフェ花まる" style="width:100%;">
</div>
<div class="f-group" style="margin-bottom:10px;">
<label>業種</label>
<input class="f-input" id="set-type" placeholder="例: カフェ・喫茶店" style="width:100%;">
</div>
<div class="f-group" style="margin-bottom:14px;">
<label>月次目標売上(円)</label>
<input type="number" class="f-input" id="set-target" placeholder="500000" style="width:100%;">
</div>
<button class="f-btn f-btn-blue" onclick="saveSettings()">💾 設定を保存</button>
</div>
<div class="s-card">
<div class="s-card-title">📂 データ読み込み</div>
<div style="margin-bottom:14px;">
<div style="font-size:12px;font-weight:800;color:var(--text2);margin-bottom:8px;">📤 MISE POS 売上CSV(複数可)</div>
<label class="upload-zone" style="padding:20px;" id="sales-drop">
<input type="file" multiple accept=".csv" onchange="handleSalesFiles(this.files)">
<div style="font-size:28px;margin-bottom:6px;">📄</div>
<div style="font-size:13px;font-weight:800;">CSVをドロップ または クリックして選択</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px;">MISE POS で出力した売上CSVを読み込みます</div>
</label>
<div id="sales-load-info" style="font-size:12px;color:var(--mint);font-weight:700;margin-top:6px;"></div>
</div>
<div style="margin-bottom:14px;">
<div style="font-size:12px;font-weight:800;color:var(--text2);margin-bottom:8px;">⚙️ MISE POS setting.txt</div>
<label class="upload-zone" style="padding:16px;" id="setting-drop">
<input type="file" accept=".txt,.json" onchange="handleSettingFile(this.files[0])">
<div style="font-size:13px;font-weight:800;">setting.txt をドロップ</div>
<div style="font-size:11px;color:var(--text2);">商品マスタを取り込みます</div>
</label>
<div id="setting-load-info" style="font-size:12px;color:var(--mint);font-weight:700;margin-top:6px;"></div>
</div>
</div>
</div>
<div class="s-card">
<div class="s-card-title">📊 読み込みデータ状態</div>
<div class="grid-3" id="data-status">
<div style="text-align:center;padding:16px;background:var(--bg);border-radius:12px;">
<div style="font-size:28px;margin-bottom:4px;">📄</div>
<div style="font-size:24px;font-weight:900;font-family:'Nunito',sans-serif;" id="ds-sales-cnt">0</div>
<div style="font-size:11px;color:var(--text2);font-weight:700;">売上レコード数</div>
</div>
<div style="text-align:center;padding:16px;background:var(--bg);border-radius:12px;">
<div style="font-size:28px;margin-bottom:4px;">📦</div>
<div style="font-size:24px;font-weight:900;font-family:'Nunito',sans-serif;" id="ds-prod-cnt">0</div>
<div style="font-size:11px;color:var(--text2);font-weight:700;">商品マスタ数</div>
</div>
<div style="text-align:center;padding:16px;background:var(--bg);border-radius:12px;">
<div style="font-size:28px;margin-bottom:4px;">📅</div>
<div style="font-size:24px;font-weight:900;font-family:'Nunito',sans-serif;" id="ds-date-range">—</div>
<div style="font-size:11px;color:var(--text2);font-weight:700;">データ期間</div>
</div>
</div>
<div style="margin-top:14px;display:flex;gap:10px;flex-wrap:wrap;">
<button class="f-btn f-btn-ghost" onclick="clearAllData()">🗑 全データをリセット</button>
<button class="f-btn f-btn-blue" onclick="exportAllCSV()">📤 データをCSV保存</button>
</div>
</div>
<div class="alert alert-info" style="margin-top:4px;">
<div class="alert-icon">💡</div>
<div class="alert-body">
<div class="alert-title">MISE POS との連携方法</div>
<div class="alert-text">① MISE POSの「📤 CSV出力」ボタンで売上CSVをダウンロード<br>② このページの「CSVを読み込む」から取り込む<br>③ setting.txtを読み込むと商品マスタが自動同期されます<br>④ データはブラウザのlocalStorageに保存されます(ページを閉じても保持)</div>
</div>
</div>
</div>
</main>
</div>
<div class="modal hidden" id="import-modal">
<div class="modal-box">
<div class="modal-title">📂 CSVを読み込む</div>
<label class="upload-zone" id="modal-drop">
<input type="file" multiple accept=".csv" id="modal-csv-input" onchange="handleModalFiles(this.files)">
<div style="font-size:36px;margin-bottom:8px;">📄</div>
<div style="font-size:15px;font-weight:800;margin-bottom:4px;">CSVをドロップ または クリックして選択</div>
<div style="font-size:12px;color:var(--text2);">MISE POS の売上CSVを読み込みます(複数ファイル可)</div>
</label>
<div id="import-info" style="margin-top:12px;font-size:13px;font-weight:700;color:var(--mint);min-height:20px;"></div>
<div class="modal-btns">
<button class="mbtn-cancel" onclick="closeImportModal()">閉じる</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
// ══════════════════════════════════════════════════════════════
// ── データ ──
// ══════════════════════════════════════════════════════════════
let salesData = JSON.parse(localStorage.getItem('mgr_sales') || '[]');
let products = JSON.parse(localStorage.getItem('mgr_products') || '[]');
let stockData = JSON.parse(localStorage.getItem('mgr_stock') || '{}');
// 商品在庫: { product_id: { qty, warn, danger, log[] } }
let ingredients = JSON.parse(localStorage.getItem('mgr_ingredients') || '[]');
// 原料マスタ: [{id,name,cat,unit,warn,danger,cost,note}]
let ingrStock = JSON.parse(localStorage.getItem('mgr_ingr_stock') || '{}');
// 原料在庫: {ingr_id: {qty, log[]}}
let recipes = JSON.parse(localStorage.getItem('mgr_recipes') || '[]');
// レシピ: [{id,prodName,ingredients:[{ingrId,amount}]}]
let expenses = JSON.parse(localStorage.getItem('mgr_expenses') || '[]');
// [{id, date, cat, amount, memo}]
let purchases = JSON.parse(localStorage.getItem('mgr_purchases') || '[]');
// [{id, date, supplier, product_id, product_name, qty, unit_price, total, memo}]
let settings = JSON.parse(localStorage.getItem('mgr_settings') || '{}');
// {store, type, target_monthly}
// 分析期間
let analysisPeriod = 'month';
let analysisFrom = '', analysisTo = '';
let rankMetric = 'amount';
let stockFilter = 'all';
let financeMonth = ''; // YYYY-MM
// Chart.js インスタンス管理
const charts = {};
// ══════════════════════════════════════════════════════════════
// ── 初期化 ──
// ══════════════════════════════════════════════════════════════
function init(){
// 時計
setInterval(()=>{
const n=new Date();
document.getElementById('hdr-clock').textContent=
n.getFullYear()+'年'+(n.getMonth()+1)+'月'+n.getDate()+'日 '+
String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0');
},1000);
// 設定適用
if(settings.store){
document.getElementById('set-store').value = settings.store;
document.getElementById('nav-store').textContent = settings.store;
}
if(settings.type) document.getElementById('set-type').value = settings.type;
if(settings.target_monthly) document.getElementById('set-target').value = settings.target_monthly;
// 今月を初期値
const now = new Date();
financeMonth = now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0');
document.getElementById('finance-month').value = financeMonth;
// 仕入れ帳の月初期値
document.getElementById('pur-filter-month').value = financeMonth;
// 日付デフォルト
const today = todayStr();
document.getElementById('exp-date').value = today;
document.getElementById('pur-date').value = today;
// 分析期間初期値
const d30 = new Date(); d30.setDate(d30.getDate()-29);
analysisFrom = d30.toISOString().slice(0,10);
analysisTo = today;
document.getElementById('an-from').value = analysisFrom;
document.getElementById('an-to').value = analysisTo;
// ドロップ設定
setupDropZone('sales-drop','modal-drop');
setupDropZone2('setting-drop');
// 日付フィールドの初期値(原料在庫)
document.getElementById('auto-calc-from').value = analysisFrom;
document.getElementById('auto-calc-to').value = today;
document.getElementById('stocktake-date').value = today;
// 商品セレクト
rebuildProductSelects();
// 原料在庫:セレクト・グリッド初期化
rebuildIngrSelects();
updateDataStatus();
renderDashboard();
updateNavBadge();
}
// ══════════════════════════════════════════════════════════════
// ── パネル切替 ──
// ══════════════════════════════════════════════════════════════
function showPanel(id, btn){
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(b=>b.classList.remove('active'));
document.getElementById('panel-'+id).classList.add('active');
if(btn) btn.classList.add('active');
// 各パネル描画
if(id==='dashboard') renderDashboard();
if(id==='stock') renderStockPanel();
if(id==='finance') renderFinance();
if(id==='analysis') renderAnalysis();
if(id==='purchase') renderPurchasePanel();
if(id==='settings') updateDataStatus();
}
// ══════════════════════════════════════════════════════════════
// ── CSVパース ──
// ══════════════════════════════════════════════════════════════
function parseCSVLine(line){
const res=[];let cur='',inQ=false;
for(let i=0;i<line.length;i++){
const c=line[i];
if(c==='"'){if(inQ&&line[i+1]==='"'){cur+='"';i++;}else inQ=!inQ;}
else if(c===','&&!inQ){res.push(cur);cur='';}
else cur+=c;
}
res.push(cur);return res;
}
function parseSalesCSV(text){
const lines=text.replace(/^\uFEFF/,'').split(/\r?\n/).filter(l=>l.trim());
if(lines.length<2) return [];
const hdrs=parseCSVLine(lines[0]);
const required=['datetime','date','product','amount'];
if(required.some(h=>!hdrs.includes(h))) return null; // 不正フォーマット
const rows=[];
for(let i=1;i<lines.length;i++){
const cells=parseCSVLine(lines[i]);
if(cells.length<2) continue;
const row={};
hdrs.forEach((h,idx)=>{row[h]=cells[idx]!==undefined?cells[idx]:''});
['hour','dow','month','price','quantity','amount','tax','discount_pct'].forEach(k=>{
if(row[k]!==undefined&&row[k]!==''){const n=parseFloat(row[k]);if(!isNaN(n))row[k]=n;}
});
if(!row.date) continue;
rows.push(row);
}
return rows;
}
function mergeSales(newRows){
if(!newRows||!newRows.length) return 0;
// txid+product_idをキーに重複排除
const existing=new Set(salesData.map(r=>`${r.txid||''}|${r.datetime||''}|${r.product_id||r.product||''}`));
let added=0;
newRows.forEach(r=>{
const key=`${r.txid||''}|${r.datetime||''}|${r.product_id||r.product||''}`;
if(!existing.has(key)){salesData.push(r);existing.add(key);added++;}
});
salesData.sort((a,b)=>(a.datetime||a.date||'').localeCompare(b.datetime||b.date||''));
localStorage.setItem('mgr_sales',JSON.stringify(salesData));
return added;
}
// ══════════════════════════════════════════════════════════════
// ── ファイル読み込みハンドラ ──
// ══════════════════════════════════════════════════════════════
function handleSalesFiles(files){
let totalAdded=0;let totalFailed=0;
const fileArr=Array.from(files);
let done=0;
fileArr.forEach(f=>{
const reader=new FileReader();
reader.onload=e=>{
const rows=parseSalesCSV(e.target.result);
if(rows===null){totalFailed++;}
else{totalAdded+=mergeSales(rows);}
done++;
if(done===fileArr.length){
const info=document.getElementById('sales-load-info');
if(info) info.textContent=`✅ ${totalAdded}件を追加しました(${totalFailed}ファイルはスキップ)`;
updateDataStatus();
renderDashboard();
showToast(`📥 ${totalAdded}件の売上データを読み込みました`);
}
};
reader.readAsText(f,'UTF-8');
});
}
function handleModalFiles(files){
const info=document.getElementById('import-info');
let totalAdded=0;let done=0;
const fileArr=Array.from(files);
fileArr.forEach(f=>{
const reader=new FileReader();
reader.onload=e=>{
const rows=parseSalesCSV(e.target.result);
if(rows!==null) totalAdded+=mergeSales(rows);
done++;
if(done===fileArr.length){
if(info) info.textContent=`✅ ${totalAdded}件を読み込みました`;
updateDataStatus();
renderDashboard();
showToast(`📥 ${totalAdded}件を読み込みました`);
}
};
reader.readAsText(f,'UTF-8');
});
}
function handleSettingFile(file){
if(!file) return;
const reader=new FileReader();
reader.onload=e=>{
try{
const s=JSON.parse(e.target.result);
if(s.products&&Array.isArray(s.products)){
products=s.products;
localStorage.setItem('mgr_products',JSON.stringify(products));
rebuildProductSelects();
updateDataStatus();
const info=document.getElementById('setting-load-info');
if(info) info.textContent=`✅ 商品マスタ ${products.length}件を取り込みました`;
showToast('📦 商品マスタを読み込みました');
} else {
showToast('⚠️ 商品情報が見つかりませんでした');
}
}catch(e){showToast('⚠️ ファイルの読み込みに失敗しました');}
};
reader.readAsText(file,'UTF-8');
}
// ドロップゾーン設定
function setupDropZone(id1, id2){
[id1,id2].forEach(id=>{
const el=document.getElementById(id);
if(!el) return;
el.addEventListener('dragover',e=>{e.preventDefault();el.classList.add('drag');});
el.addEventListener('dragleave',()=>el.classList.remove('drag'));
el.addEventListener('drop',e=>{
e.preventDefault();el.classList.remove('drag');
handleModalFiles(e.dataTransfer.files);
const inp=document.getElementById('modal-csv-input');
if(inp) inp.value='';
});
});
}
function setupDropZone2(id){
const el=document.getElementById(id);
if(!el) return;
el.addEventListener('dragover',e=>{e.preventDefault();el.classList.add('drag');});
el.addEventListener('dragleave',()=>el.classList.remove('drag'));
el.addEventListener('drop',e=>{
e.preventDefault();el.classList.remove('drag');
if(e.dataTransfer.files[0]) handleSettingFile(e.dataTransfer.files[0]);
});
}
function openImportModal(){ document.getElementById('import-modal').classList.remove('hidden'); }
function closeImportModal(){ document.getElementById('import-modal').classList.add('hidden'); }
document.getElementById('import-modal').addEventListener('click',e=>{
if(e.target===document.getElementById('import-modal')) closeImportModal();
});
// ══════════════════════════════════════════════════════════════
// ── ユーティリティ ──
// ══════════════════════════════════════════════════════════════
function todayStr(){
const n=new Date();
return n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0')+'-'+String(n.getDate()).padStart(2,'0');
}
function monthStr(d){ return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); }
function yen(v){ return '¥'+Math.round(v).toLocaleString(); }
function pct(v,base){ return base>0?((v/base)*100).toFixed(1)+'%':'—'; }
function DOW_LABEL(){ return ['日','月','火','水','木','金','土']; }
function escH(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
function filteredSales(from, to){
const f=from||'0000-00-00', t=to||'9999-99-99';
return salesData.filter(r=>r.date>=f && r.date<=t);
}
function salesForMonth(ym){
return salesData.filter(r=>r.date&&r.date.startsWith(ym));
}
function txAmount(rows){
return rows.reduce((s,r)=>s+(r.amount||0),0);
}
function txCount(rows){
return new Set(rows.map(r=>r.txid||(r.datetime||r.date||'')+'|'+(r.product||''))).size;
}
function uniqueTxIds(rows){
const s=new Set();
rows.forEach(r=>{ const k=r.txid||(r.datetime+r.product);s.add(k); });
return s.size;
}
// Chart.js インスタンスの安全な作成・破棄
function makeChart(id, config){
if(charts[id]) charts[id].destroy();
const canvas=document.getElementById(id);
if(!canvas) return;
charts[id]=new Chart(canvas.getContext('2d'), config);
return charts[id];
}
const COLORS=['#3b6ef5','#22c55e','#f59e0b','#ef4444','#8b5cf6','#0ea5e9','#f97316','#ec4899','#14b8a6','#a3e635'];
// ══════════════════════════════════════════════════════════════
// ── ダッシュボード ──
// ══════════════════════════════════════════════════════════════
function renderDashboard(){
const today = todayStr();
const now = new Date();
const thisMonth = monthStr(now);
const lastMonth = monthStr(new Date(now.getFullYear(),now.getMonth()-1,1));
// 日付ラベル
const DOW=['日','月','火','水','木','金','土'];
document.getElementById('dash-date-label').textContent=
`${now.getFullYear()}年${now.getMonth()+1}月${now.getDate()}日(${DOW[now.getDay()]})の経営状況`;
// KPI計算
const todayRows = salesData.filter(r=>r.date===today);
const yesterdayRows = salesData.filter(r=>{
const y=new Date(today); y.setDate(y.getDate()-1);
return r.date===y.toISOString().slice(0,10);
});
const thisMonthRows = salesForMonth(thisMonth);
const lastMonthRows = salesForMonth(lastMonth);
const todaySales=txAmount(todayRows);
const ydSales=txAmount(yesterdayRows);
const mSales=txAmount(thisMonthRows);
const lmSales=txAmount(lastMonthRows);
const mTx=uniqueTxIds(thisMonthRows);
const lmTx=uniqueTxIds(lastMonthRows);
const mAtv=mTx>0?mSales/mTx:0;
const lmAtv=lmTx>0?lmSales/lmTx:0;
document.getElementById('kpi-today-sales').textContent=yen(todaySales);
document.getElementById('kpi-month-sales').textContent=yen(mSales);
document.getElementById('kpi-month-tx').innerHTML=`${mTx}<small>件</small>`;
document.getElementById('kpi-atv').textContent=yen(mAtv);
// 差分表示
const diffEl=(id,curr,prev,isYen=true)=>{
const el=document.getElementById(id);
if(!el) return;
if(prev===0){el.textContent='前期間データなし';el.className='kpi-diff flat';return;}
const d=curr-prev;const dp=(d/prev*100).toFixed(1);
const sign=d>=0?'▲':'▼';
el.className='kpi-diff '+(d>=0?'up':'down');
el.textContent=`${sign}${isYen?yen(Math.abs(d)):Math.abs(d).toLocaleString()} (${Math.abs(dp)}%)`;
};
diffEl('kpi-today-diff',todaySales,ydSales);
diffEl('kpi-month-diff',mSales,lmSales);
diffEl('kpi-month-tx-diff',mTx,lmTx,false);
diffEl('kpi-atv-diff',mAtv,lmAtv);
// アラート
renderDashAlerts(mSales);
// 直近30日グラフ
renderDailyChart(today);
// カテゴリ円グラフ
renderCatPieChart(thisMonthRows);
// 在庫アラート
renderDashStockAlerts();
// トップ商品
renderDashTopProducts(thisMonthRows);
}
function renderDashAlerts(mSales){
const container=document.getElementById('dash-alerts');
const alerts=[];
// 目標達成率
if(settings.target_monthly){
const target=parseInt(settings.target_monthly)||0;
const rate=mSales/target*100;
if(rate<50){
const now=new Date();const dom=now.getDate();const totalDays=new Date(now.getFullYear(),now.getMonth()+1,0).getDate();
const expectedRate=dom/totalDays*100;
if(rate<expectedRate*0.7){
alerts.push({type:'bad',icon:'⚠️',title:'月次目標が大幅に未達です',text:`目標 ${yen(target)} に対して現在 ${rate.toFixed(0)}%。日割りペースを大きく下回っています。`});
}
}
}
// 在庫アラート数
const alertCount=getStockAlertCount();
if(alertCount.danger>0) alerts.push({type:'bad',icon:'🔴',title:`在庫危険警告: ${alertCount.danger}商品`,text:'危険ライン以下の商品があります。至急補充をご確認ください。'});
else if(alertCount.warn>0) alerts.push({type:'warn',icon:'🟡',title:`在庫警告: ${alertCount.warn}商品`,text:'警告ライン以下の商品があります。早めに補充を検討してください。'});
container.innerHTML=alerts.map(a=>`
<div class="alert alert-${a.type}">
<div class="alert-icon">${a.icon}</div>
<div class="alert-body"><div class="alert-title">${a.title}</div><div class="alert-text">${a.text}</div></div>
</div>`).join('');
}
function renderDailyChart(today){
const days=[];const sales30=[];
for(let i=29;i>=0;i--){
const d=new Date(today);d.setDate(d.getDate()-i);
const ds=d.toISOString().slice(0,10);
days.push(ds.slice(5));
sales30.push(txAmount(salesData.filter(r=>r.date===ds)));
}
makeChart('ch-daily',{
type:'bar',
data:{labels:days,datasets:[{label:'売上',data:sales30,backgroundColor:COLORS[0]+'88',borderColor:COLORS[0],borderWidth:2,borderRadius:4}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{callback:v=>v>=1000?yen(v/1000)+'k':yen(v),font:{size:10}},grid:{color:'rgba(0,0,0,.04)'}},x:{ticks:{maxTicksLimit:10,font:{size:10}},grid:{display:false}}}}
});
}
function renderCatPieChart(rows){
const catMap={};
rows.forEach(r=>{ const c=r.category||'その他'; catMap[c]=(catMap[c]||0)+(r.amount||0); });
const cats=Object.keys(catMap).sort((a,b)=>catMap[b]-catMap[a]);
makeChart('ch-cat-pie',{
type:'doughnut',
data:{labels:cats,datasets:[{data:cats.map(c=>catMap[c]),backgroundColor:COLORS.slice(0,cats.length),borderWidth:2,borderColor:'#fff'}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{font:{size:11},boxWidth:12}}}}
});
}
function renderDashTopProducts(rows){
const prodMap={};
rows.forEach(r=>{ const k=r.product||'不明'; prodMap[k]=(prodMap[k]||0)+(r.amount||0); });
const sorted=Object.entries(prodMap).sort((a,b)=>b[1]-a[1]).slice(0,8);
const maxAmt=sorted[0]?sorted[0][1]:1;
const container=document.getElementById('dash-top-products');
container.innerHTML=sorted.map(([name,amt],i)=>`
<div class="rank-item">
<div class="rank-no${i===0?' top1':i===1?' top2':i===2?' top3':''}">${i+1}</div>
<div class="rank-bar-wrap">
<div class="rank-name">${escH(name)}</div>
<div class="rank-bar-track"><div class="rank-bar-fill" style="width:${Math.round(amt/maxAmt*100)}%"></div></div>
</div>
<div class="rank-val">${yen(amt)}</div>
</div>`).join('');
if(!sorted.length) container.innerHTML='<div style="color:var(--text3);text-align:center;padding:20px;font-size:13px;">データがありません</div>';
}
// ══════════════════════════════════════════════════════════════
// ── 在庫管理 ──
// ══════════════════════════════════════════════════════════════
// ── ユーティリティ共通 ──
// ══════════════════════════════════════════════════════════════
function getProductById(id){ return products.find(p=>p.id===id)||null; }
function buildProductsFromSales(){
const map={};
salesData.forEach(r=>{
const id=r.product_id||r.product||'';
if(!map[id]) map[id]={id,name:r.product||id,cat:r.category||'',icon:'📦',price:r.price||0,tax:r.tax||10};
});
return Object.values(map);
}
function fmtQty(v,unit){
const n=parseFloat(v)||0;
return (n%1===0?n:n.toFixed(2))+(unit?(' '+unit):'');
}
// ══════════════════════════════════════════════════════════════
// ── 商品在庫(stockData) ──
// ══════════════════════════════════════════════════════════════
function getStockItem(prodId){
if(!stockData[prodId]) stockData[prodId]={qty:0,warn:5,danger:2,log:[]};
return stockData[prodId];
}
function getStockAlertCount(){
let danger=0,warn=0;
// 商品在庫
(products.length?products:buildProductsFromSales()).forEach(p=>{
const s=stockData[p.id];
if(!s) return;
if(s.qty<=s.danger) danger++;
else if(s.qty<=s.warn) warn++;
});
// 原料在庫
ingredients.forEach(ig=>{
const s=ingrStock[ig.id];
if(!s) return;
if(s.qty<=ig.danger) danger++;
else if(s.qty<=ig.warn) warn++;
});
return {danger,warn};
}
function updateStock(){
const prodId=document.getElementById('stock-prod-sel').value;
const op=document.getElementById('stock-op').value;
const qty=parseFloat(document.getElementById('stock-qty').value||'0');
const memo=document.getElementById('stock-memo').value.trim();
if(!prodId){showToast('⚠️ 商品を選択してください');return;}
if(isNaN(qty)||qty<0){showToast('⚠️ 正しい数量を入力してください');return;}
const s=getStockItem(prodId);
const before=s.qty;
if(op==='set') s.qty=qty;
else if(op==='add') s.qty=+(s.qty+qty).toFixed(4);
else if(op==='sub') s.qty=+Math.max(0,s.qty-qty).toFixed(4);
s.log=s.log||[];
s.log.unshift({date:todayStr(),op,qty,memo,before,after:s.qty});
if(s.log.length>50) s.log=s.log.slice(0,50);
localStorage.setItem('mgr_stock',JSON.stringify(stockData));
showToast(`✅ 在庫を更新しました(${before}→${s.qty})`);
document.getElementById('stock-qty').value='';
document.getElementById('stock-memo').value='';
renderStockGrid();
updateNavBadge();
}
function saveAlertSetting(){
const prodId=document.getElementById('alert-prod-sel').value;
const w=parseFloat(document.getElementById('alert-warn').value||'5');
const d=parseFloat(document.getElementById('alert-danger').value||'2');
if(!prodId){showToast('⚠️ 商品を選択してください');return;}
const s=getStockItem(prodId);
s.warn=w;s.danger=d;
localStorage.setItem('mgr_stock',JSON.stringify(stockData));
showToast('💾 アラート設定を保存しました');
renderStockGrid();
updateNavBadge();
}
function renderStockPanel(){
rebuildProductSelects();
// 商品在庫タブ
renderStockGrid();
// 原料在庫タブ
rebuildIngrSelects();
renderIngrGrid();
renderIngrListTable();
renderRecipeList();
renderStocktakeTable();
updateNavBadge();
}
// 在庫パネルのメインタブ切替(商品 / 原料)
let stockMainTab='product'; // 'product' | 'ingredient'
function showStockMainTab(tab, btn){
stockMainTab=tab;
document.querySelectorAll('#stock-main-tabs .period-btn').forEach(b=>b.classList.remove('active'));
if(btn) btn.classList.add('active');
document.getElementById('stock-product-area').style.display = tab==='product'?'block':'none';
document.getElementById('stock-ingr-area').style.display = tab==='ingredient'?'block':'none';
if(tab==='ingredient') showStockTab('list', document.querySelector('#stock-tab-bar .period-btn'));
}
// 原料在庫サブタブ切替
let ingrFilter='all';
function showStockTab(tab, btn){
['list','ingr-edit','adjust','recipe','stocktake'].forEach(t=>{
const el=document.getElementById('stock-tab-'+t);
if(el) el.style.display = t===tab?'block':'none';
});
document.querySelectorAll('#stock-tab-bar .period-btn').forEach(b=>b.classList.remove('active'));
if(btn) btn.classList.add('active');
if(tab==='list') renderIngrGrid();
if(tab==='ingr-edit'){ renderIngrListTable(); rebuildIngrCatList(); }
if(tab==='adjust'){ rebuildAdjIngrSel(); renderAdjLog(); }
if(tab==='recipe'){ renderRecipeList(); rebuildRecipeProdList(); }
if(tab==='stocktake') renderStocktakeTable();
}
function filterStock(type, btn){
stockFilter=type;
document.querySelectorAll('#sf-all,#sf-danger,#sf-warn,#sf-ok').forEach(b=>b.classList.remove('active'));
if(btn) btn.classList.add('active');
renderStockGrid();
}
function filterIngr(type, btn){
ingrFilter=type;
document.querySelectorAll('#sf-all,#sf-danger,#sf-warn,#sf-ok').forEach(b=>b.classList.remove('active'));
if(btn) btn.classList.add('active');
renderIngrGrid();
}
function renderStockGrid(){
const q=(document.getElementById('stock-search')?.value||'').toLowerCase();
const grid=document.getElementById('stock-grid');
if(!grid) return;
let prods=products.length?products:buildProductsFromSales();
if(q) prods=prods.filter(p=>p.name.toLowerCase().includes(q)||((p.cat||'').toLowerCase().includes(q)));
prods=prods.filter(p=>{
const s=stockData[p.id];
if(!s) return stockFilter==='all'||stockFilter==='ok';
if(stockFilter==='danger') return s.qty<=s.danger;
if(stockFilter==='warn') return s.qty>s.danger && s.qty<=s.warn;
if(stockFilter==='ok') return s.qty>s.warn;
return true;
});
if(!prods.length){
grid.innerHTML='<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text3);font-size:14px;font-weight:700;">商品がありません</div>';
return;
}
grid.innerHTML=prods.map(p=>{
const s=getStockItem(p.id);
const isDanger=s.qty<=s.danger;
const isWarn=!isDanger&&s.qty<=s.warn;
const statusClass=isDanger?'warn-out':isWarn?'warn-low':'';
const alertBadge=isDanger?'🔴':isWarn?'🟡':'';
const prog=s.warn>0?Math.min(100,Math.round(s.qty/Math.max(s.warn*2,1)*100)):100;
const progColor=isDanger?'var(--coral)':isWarn?'var(--amber)':'var(--mint)';
return `<div class="stock-card ${statusClass}">
${alertBadge?`<div class="stock-alert-badge">${alertBadge}</div>`:''}
<span class="stock-icon">${p.icon||'📦'}</span>
<div class="stock-name">${escH(p.name)}</div>
<div class="stock-cat">${escH(p.cat||'')}</div>
<div class="stock-qty">${s.qty}<small>個</small></div>
<div class="prog-bar"><div class="prog-fill" style="width:${prog}%;background:${progColor}"></div></div>
<div class="stock-meta">
<span class="chip chip-gray" style="font-size:10px;">警告:${s.warn}</span>
<span class="chip chip-gray" style="font-size:10px;">危険:${s.danger}</span>
${p.price?`<span class="chip chip-blue" style="font-size:10px;">¥${p.price.toLocaleString()}</span>`:''}
</div>
<div style="margin-top:8px;font-size:10px;color:var(--text3);">
${s.log&&s.log[0]?`最終更新: ${s.log[0].date} (${escH(s.log[0].memo||s.log[0].op)})`:'更新なし'}
</div>
</div>`;
}).join('');
}
// ══════════════════════════════════════════════════════════════
// ── 原料マスタ(ingredients / ingrStock) ──
// ingredients: [{id,name,cat,unit,warn,danger,cost,note}]
// ingrStock: {ingr_id: {qty, log:[{date,op,qty,after,memo}]}}
// recipes: [{id,prodName,ingredients:[{ingrId,amount}]}]
// ══════════════════════════════════════════════════════════════
function getIngrStock(ingrId){
if(!ingrStock[ingrId]) ingrStock[ingrId]={qty:0,log:[]};
return ingrStock[ingrId];
}
function saveIngrData(){
localStorage.setItem('mgr_ingredients',JSON.stringify(ingredients));
localStorage.setItem('mgr_ingr_stock',JSON.stringify(ingrStock));
}
function saveRecipes(){
localStorage.setItem('mgr_recipes',JSON.stringify(recipes));
}
// ── 原料登録・編集 ──
let editingIngrId=null;
function saveIngredient(){
const name=document.getElementById('ingr-name').value.trim();
const cat=document.getElementById('ingr-cat').value.trim();
const unit=document.getElementById('ingr-unit').value;
const qty=parseFloat(document.getElementById('ingr-qty').value||'0');
const cost=parseFloat(document.getElementById('ingr-cost').value||'0');
const warn=parseFloat(document.getElementById('ingr-warn').value||'0');
const danger=parseFloat(document.getElementById('ingr-danger').value||'0');
const note=document.getElementById('ingr-note').value.trim();
if(!name){showToast('⚠️ 原料名を入力してください');return;}
if(!unit){showToast('⚠️ 単位を選択してください');return;}
if(editingIngrId){
const ig=ingredients.find(i=>i.id===editingIngrId);
if(ig){ Object.assign(ig,{name,cat,unit,warn,danger,cost,note}); }
// 在庫数は直接編集しない(入出庫で管理)
} else {
const newId='IG'+Date.now();
ingredients.push({id:newId,name,cat,unit,warn,danger,cost,note});
// 初期在庫をセット
const s=getIngrStock(newId);
if(qty>0){
const before=s.qty;
s.qty=qty;
s.log.unshift({date:todayStr(),op:'set',qty,after:qty,memo:'初期登録',before});
}
}
saveIngrData();
rebuildIngrCatList();
rebuildIngrSelects();
renderIngrListTable();
renderIngrGrid();
updateNavBadge();
const fb=document.getElementById('ingr-feedback');
if(fb){ fb.textContent='✅ 保存しました!'; setTimeout(()=>fb.textContent='',2000); }
resetIngrForm();
}
function resetIngrForm(){
editingIngrId=null;
['ingr-name','ingr-cat','ingr-qty','ingr-cost','ingr-warn','ingr-danger','ingr-note'].forEach(id=>{
const el=document.getElementById(id); if(el) el.value='';
});
document.getElementById('ingr-unit').value='g';
document.getElementById('ingr-form-title').textContent='➕ 原料を新規登録';
}
function editIngredient(id){
const ig=ingredients.find(i=>i.id===id);
if(!ig) return;
editingIngrId=id;
document.getElementById('ingr-name').value=ig.name;
document.getElementById('ingr-cat').value=ig.cat||'';
document.getElementById('ingr-unit').value=ig.unit||'g';
document.getElementById('ingr-cost').value=ig.cost||'';
document.getElementById('ingr-warn').value=ig.warn||'';
document.getElementById('ingr-danger').value=ig.danger||'';
document.getElementById('ingr-note').value=ig.note||'';
// qty フィールドは編集時は空(入出庫タブで操作)
document.getElementById('ingr-qty').value='';
document.getElementById('ingr-form-title').textContent='✏️ 原料を編集';
showStockTab('ingr-edit', document.querySelectorAll('#stock-tab-bar .period-btn')[1]);
}
function deleteIngredient(id){
if(!confirm('この原料を削除します。在庫ログも削除されます。よろしいですか?')) return;
ingredients=ingredients.filter(i=>i.id!==id);
delete ingrStock[id];
recipes=recipes.map(r=>({...r,ingredients:r.ingredients.filter(ri=>ri.ingrId!==id)}));
saveIngrData();
saveRecipes();
rebuildIngrSelects();
renderIngrListTable();
renderIngrGrid();
updateNavBadge();
showToast('🗑 原料を削除しました');
}
function renderIngrListTable(){
const tbody=document.getElementById('ingr-list-tbody');
if(!tbody) return;
if(!ingredients.length){
tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--text3);padding:20px;">原料が未登録です</td></tr>';
return;
}
tbody.innerHTML=ingredients.map(ig=>{
const s=getIngrStock(ig.id);
return `<tr>
<td style="font-weight:800;">${escH(ig.name)}</td>
<td>${escH(ig.cat||'—')}</td>
<td><span class="ingr-unit">${escH(ig.unit)}</span></td>
<td class="num">${fmtQty(s.qty,'')}</td>
<td class="num">${ig.cost?'¥'+ig.cost:'-'}</td>
<td style="white-space:nowrap;">
<button class="ingr-act-btn edit" onclick="editIngredient('${ig.id}')">編集</button>
<button class="ingr-act-btn del" onclick="deleteIngredient('${ig.id}')" style="margin-left:4px;">削除</button>
</td>
</tr>`;
}).join('');
}
// ── 原料在庫グリッド ──
function renderIngrGrid(){
const q=(document.getElementById('ingr-search')?.value||'').toLowerCase();
const grid=document.getElementById('ingr-grid');
if(!grid) return;
let list=ingredients;
if(q) list=list.filter(ig=>ig.name.toLowerCase().includes(q)||(ig.cat||'').toLowerCase().includes(q));
list=list.filter(ig=>{
const s=ingrStock[ig.id];
const qty=s?s.qty:0;
if(ingrFilter==='danger') return qty<=ig.danger;
if(ingrFilter==='warn') return qty>ig.danger && qty<=ig.warn;
if(ingrFilter==='ok') return qty>ig.warn;
return true;
});
if(!list.length){
grid.innerHTML=ingredients.length
? '<div style="grid-column:1/-1;text-align:center;padding:30px;color:var(--text3);font-size:13px;">該当する原料がありません</div>'
: `<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text3);">
<div style="font-size:40px;margin-bottom:10px;">🧂</div>
<div style="font-size:14px;font-weight:800;">原料がまだ登録されていません</div>
<div style="font-size:12px;margin-top:6px;">「原料登録」タブから追加してください</div>
</div>`;
return;
}
grid.innerHTML=list.map(ig=>{
const s=getIngrStock(ig.id);
const qty=s.qty;
const isDanger=qty<=ig.danger;
const isWarn=!isDanger&&qty<=ig.warn;
const statusClass=isDanger?'warn-out':isWarn?'warn-low':'';
const alertBadge=isDanger?'🔴':isWarn?'🟡':'';
const prog=ig.warn>0?Math.min(100,Math.round(qty/Math.max(ig.warn*2,0.001)*100)):100;
const progColor=isDanger?'var(--coral)':isWarn?'var(--amber)':'var(--mint)';
return `<div class="ingr-card ${statusClass}">
${alertBadge?`<div class="ingr-alert">${alertBadge}</div>`:''}
<span class="ingr-unit">${escH(ig.unit)}</span>
<div class="ingr-name">${escH(ig.name)}</div>
<div class="ingr-cat">${escH(ig.cat||'')}</div>
<div class="ingr-qty-wrap">
<div class="ingr-qty">${fmtQty(qty,'')}</div>
<div class="ingr-qty-unit">${escH(ig.unit)}</div>
</div>
<div class="prog-bar" style="margin-top:6px;"><div class="prog-fill" style="width:${prog}%;background:${progColor}"></div></div>
<div class="ingr-theory" style="margin-top:3px;">
警告:${ig.warn}${ig.unit} / 危険:${ig.danger}${ig.unit}
${ig.cost?` / ¥${ig.cost}/単位`:''}
</div>
<div class="ingr-actions" style="margin-top:8px;">
<button class="ingr-act-btn edit" onclick="editIngredient('${ig.id}')">✏️ 編集</button>
<button class="ingr-act-btn edit" onclick="quickAdjIngr('${ig.id}')" style="background:var(--mint-l);color:#16a34a;">📥 入出庫</button>
</div>
<div style="font-size:10px;color:var(--text3);margin-top:6px;">
${s.log&&s.log[0]?'最終更新: '+s.log[0].date+' ('+escH(s.log[0].memo||s.log[0].op)+')':'更新なし'}
</div>
</div>`;
}).join('');
}
// 在庫一覧から直接入出庫タブへジャンプ
function quickAdjIngr(id){
showStockTab('adjust', document.querySelectorAll('#stock-tab-bar .period-btn')[2]);
const sel=document.getElementById('adj-ingr-sel');
if(sel){ sel.value=id; onAdjIngrChange(); }
}
// ── 入出庫 ──
function rebuildAdjIngrSel(){
const sel=document.getElementById('adj-ingr-sel');
if(!sel) return;
sel.innerHTML=ingredients.map(ig=>`<option value="${ig.id}">${escH(ig.name)} (${escH(ig.unit)})</option>`).join('');
onAdjIngrChange();
}
function onAdjIngrChange(){
const id=document.getElementById('adj-ingr-sel')?.value;
if(!id) return;
const ig=ingredients.find(i=>i.id===id);
const s=getIngrStock(id);
const el=document.getElementById('adj-current');
if(el) el.textContent=`${fmtQty(s.qty,ig?.unit||'')}`;
const costEl=document.getElementById('adj-unit-cost');
if(costEl&&ig?.cost) costEl.value=ig.cost;
onAdjQtyChange();
renderAdjLog();
}
function onAdjQtyChange(){
const id=document.getElementById('adj-ingr-sel')?.value;
const op=document.getElementById('adj-op')?.value;
const qty=parseFloat(document.getElementById('adj-qty')?.value||'0');
const s=id?getIngrStock(id):null;
const ig=id?ingredients.find(i=>i.id===id):null;
const after=document.getElementById('adj-after');
if(!after||!s) return;
let result=s.qty;
if(op==='add') result=+(s.qty+qty).toFixed(4);
else if(op==='sub') result=+Math.max(0,s.qty-qty).toFixed(4);
else if(op==='set') result=qty;
after.textContent=`${fmtQty(result, ig?.unit||'')}`;
}
function adjustIngr(){
const id=document.getElementById('adj-ingr-sel').value;
const op=document.getElementById('adj-op').value;
const qty=parseFloat(document.getElementById('adj-qty').value||'0');
const unitCost=parseFloat(document.getElementById('adj-unit-cost').value||'0');
const memo=document.getElementById('adj-memo').value.trim();
if(!id){showToast('⚠️ 原料を選択してください');return;}
if(isNaN(qty)||qty<0){showToast('⚠️ 正しい数量を入力してください');return;}
const s=getIngrStock(id);
const ig=ingredients.find(i=>i.id===id);
const before=s.qty;
if(op==='add') s.qty=+(s.qty+qty).toFixed(4);
else if(op==='sub') s.qty=+Math.max(0,s.qty-qty).toFixed(4);
else if(op==='set') s.qty=qty;
const logEntry={
date:new Date().toISOString().slice(0,16).replace('T',' '),
op,qty,before,after:s.qty,memo,unitCost
};
s.log=s.log||[];
s.log.unshift(logEntry);
if(s.log.length>100) s.log=s.log.slice(0,100);
saveIngrData();
const opLabel={add:'追加',sub:'減少',set:'セット'}[op]||op;
showToast(`✅ ${ig?.name||'原料'}を${opLabel}しました(${before}→${s.qty}${ig?.unit||''})`);
document.getElementById('adj-qty').value='';
document.getElementById('adj-memo').value='';
onAdjIngrChange();
renderIngrGrid();
updateNavBadge();
}
function renderAdjLog(){
const id=document.getElementById('adj-ingr-sel')?.value;
const tbody=document.getElementById('adj-log-tbody');
const nameEl=document.getElementById('adj-log-ingr-name');
if(!tbody) return;
if(!id||!ingredients.find(i=>i.id===id)){
tbody.innerHTML='<tr><td colspan="5" style="text-align:center;color:var(--text3);padding:16px;">原料を選択してください</td></tr>';
return;
}
const ig=ingredients.find(i=>i.id===id);
if(nameEl) nameEl.textContent=ig.name;
const s=getIngrStock(id);
const log=s.log||[];
if(!log.length){
tbody.innerHTML='<tr><td colspan="5" style="text-align:center;color:var(--text3);padding:16px;">入出庫記録がありません</td></tr>';
return;
}
const OP_LABEL={add:'📥追加',sub:'📤減少',set:'🔄セット'};
tbody.innerHTML=log.slice(0,30).map(l=>`
<tr>
<td style="font-size:12px;">${escH(l.date||'')}</td>
<td>${OP_LABEL[l.op]||escH(l.op)}</td>
<td class="num">${fmtQty(l.qty, ig.unit)}</td>
<td class="num">${fmtQty(l.after, ig.unit)}</td>
<td style="font-size:11px;color:var(--text2);">${escH(l.memo||'')}</td>
</tr>`).join('');
}
// ── レシピ ──
let recipeIngrRows=[];
function addRecipeIngrRow(ingrId='', amount=''){
const container=document.getElementById('recipe-ingr-rows');
if(!container) return;
const rowId='rrow-'+Date.now()+'-'+Math.random().toString(36).slice(2,6);
const ingrOpts=ingredients.map(ig=>`<option value="${ig.id}" ${ig.id===ingrId?'selected':''}>${escH(ig.name)} (${ig.unit})</option>`).join('');
const row=document.createElement('div');
row.id=rowId;
row.style.cssText='display:flex;gap:8px;align-items:center;background:var(--bg2);padding:8px 10px;border-radius:10px;';
row.innerHTML=`
<select class="f-input" style="flex:1;" data-role="ingr-sel">
<option value="">— 原料を選択 —</option>${ingrOpts}
</select>
<input type="number" class="f-input" placeholder="使用量" min="0" step="0.01" style="width:100px;" value="${escH(String(amount))}" data-role="amount">
<span class="ingr-unit" id="${rowId}-unit">${ingrId?((ingredients.find(i=>i.id===ingrId)?.unit)||''):'—'}</span>
<button onclick="document.getElementById('${rowId}').remove()" style="border:none;background:none;color:var(--text3);cursor:pointer;font-size:18px;">✕</button>
`;
row.querySelector('[data-role="ingr-sel"]').addEventListener('change', function(){
const ig=ingredients.find(i=>i.id===this.value);
document.getElementById(rowId+'-unit').textContent=ig?ig.unit:'—';
});
container.appendChild(row);
}
function saveRecipe(){
const prodName=document.getElementById('recipe-prod-name').value.trim();
if(!prodName){showToast('⚠️ 商品名を入力してください');return;}
const rows=document.getElementById('recipe-ingr-rows').querySelectorAll('[id^="rrow-"]');
const ingrList=[];
let valid=true;
rows.forEach(row=>{
const ingrId=row.querySelector('[data-role="ingr-sel"]').value;
const amount=parseFloat(row.querySelector('[data-role="amount"]').value||'0');
if(!ingrId||isNaN(amount)||amount<=0){ valid=false; return; }
ingrList.push({ingrId,amount});
});
if(!valid||!ingrList.length){showToast('⚠️ 原料と使用量をすべて正しく入力してください');return;}
// 同名商品のレシピは上書き
const existing=recipes.findIndex(r=>r.prodName===prodName);
if(existing>=0){ recipes[existing]={...recipes[existing],ingredients:ingrList}; }
else { recipes.push({id:'RC'+Date.now(),prodName,ingredients:ingrList}); }
saveRecipes();
document.getElementById('recipe-prod-name').value='';
document.getElementById('recipe-ingr-rows').innerHTML='';
document.getElementById('recipe-feedback').textContent='✅ レシピを保存しました!';
setTimeout(()=>{ const el=document.getElementById('recipe-feedback');if(el)el.textContent=''; },2000);
renderRecipeList();
}
function deleteRecipe(id){
recipes=recipes.filter(r=>r.id!==id);
saveRecipes();
renderRecipeList();
showToast('🗑 レシピを削除しました');
}
function renderRecipeList(){
const container=document.getElementById('recipe-list');
if(!container) return;
if(!recipes.length){
container.innerHTML='<div style="text-align:center;color:var(--text3);padding:30px;font-size:13px;">レシピがまだありません</div>';
return;
}
container.innerHTML=recipes.map(r=>{
const ingrCount=r.ingredients.length;
return `<div class="recipe-card">
<div class="recipe-head" onclick="toggleRecipeBody('${r.id}')">
<span class="recipe-prod-icon">🍽️</span>
<span class="recipe-prod-name">${escH(r.prodName)}</span>
<span class="recipe-ing-count">${ingrCount}種類</span>
<span class="recipe-toggle" id="rtog-${r.id}">▾</span>
</div>
<div class="recipe-body" id="rbody-${r.id}">
${r.ingredients.map(ri=>{
const ig=ingredients.find(i=>i.id===ri.ingrId);
return `<div class="recipe-ing-row">
<span style="font-weight:700">${ig?escH(ig.name):'<span style="color:var(--coral)">削除済み</span>'}</span>
<span style="color:var(--text2)">${fmtQty(ri.amount, ig?.unit||'')}</span>
</div>`;
}).join('')}
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="f-btn f-btn-ghost" style="font-size:12px;padding:5px 12px;" onclick="loadRecipeToEdit('${r.id}')">✏️ 編集</button>
<button class="f-btn f-btn-coral" style="font-size:12px;padding:5px 12px;" onclick="deleteRecipe('${r.id}')">🗑 削除</button>
</div>
</div>
</div>`;
}).join('');
}
function toggleRecipeBody(id){
const body=document.getElementById('rbody-'+id);
const tog=document.getElementById('rtog-'+id);
if(!body) return;
const isOpen=body.classList.toggle('open');
if(tog) tog.classList.toggle('open',isOpen);
}
function loadRecipeToEdit(id){
const r=recipes.find(rc=>rc.id===id);
if(!r) return;
document.getElementById('recipe-prod-name').value=r.prodName;
document.getElementById('recipe-ingr-rows').innerHTML='';
r.ingredients.forEach(ri=>addRecipeIngrRow(ri.ingrId,ri.amount));
// 編集フォームが表示されているタブに切り替え
const btn=document.querySelectorAll('#stock-tab-bar .period-btn')[3]; // レシピタブ
showStockTab('recipe',btn);
}
// ── 売上からの自動消費計算 ──
function calcAutoConsumption(){
const from=document.getElementById('auto-calc-from').value;
const to=document.getElementById('auto-calc-to').value;
const container=document.getElementById('auto-calc-result');
if(!recipes.length){
container.innerHTML='<div class="alert alert-warn"><div class="alert-icon">⚠️</div><div class="alert-body"><div class="alert-title">レシピが未登録です</div><div class="alert-text">「レシピ」タブから商品ごとの原料使用量を登録してください。</div></div></div>';
return;
}
const rows=salesData.filter(r=>(!from||r.date>=from)&&(!to||r.date<=to));
if(!rows.length){
container.innerHTML='<div class="alert alert-warn"><div class="alert-icon">⚠️</div><div class="alert-body"><div class="alert-title">該当期間に売上データがありません</div></div></div>';
return;
}
// 商品別販売数を集計
const soldMap={};
rows.forEach(r=>{
const name=r.product||'';
soldMap[name]=(soldMap[name]||0)+(parseFloat(r.quantity)||1);
});
// レシピに従って原料消費量を計算
const consumeMap={}; // ingrId -> {ig, amount}
let matched=0;
Object.entries(soldMap).forEach(([prodName,qty])=>{
// 商品名が完全一致 or 前方一致でレシピを探す
const recipe=recipes.find(r=>r.prodName===prodName||prodName.startsWith(r.prodName)||r.prodName.startsWith(prodName));
if(!recipe) return;
matched++;
recipe.ingredients.forEach(ri=>{
if(!consumeMap[ri.ingrId]) consumeMap[ri.ingrId]={ig:ingredients.find(i=>i.id===ri.ingrId),amount:0};
consumeMap[ri.ingrId].amount+=ri.amount*qty;
});
});
if(!Object.keys(consumeMap).length){
container.innerHTML=`<div class="alert alert-warn"><div class="alert-icon">ℹ️</div><div class="alert-body"><div class="alert-title">マッチするレシピが見つかりませんでした</div><div class="alert-text">売上商品名(${Object.keys(soldMap).join('、')})とレシピの商品名が一致するか確認してください。</div></div></div>`;
return;
}
// 結果テーブルを生成
const rows2=Object.entries(consumeMap).map(([ingrId,{ig,amount}])=>{
const s=getIngrStock(ingrId);
const after=+(s.qty-amount).toFixed(4);
return {ingrId,ig,amount:+amount.toFixed(4),currentQty:s.qty,after};
});
container.innerHTML=`
<div style="font-size:13px;font-weight:700;color:var(--text2);margin-bottom:8px;">
計算結果(${from||'全期間'} 〜 ${to||''}): 売上${rows.length}件、マッチ商品${matched}種類
</div>
<div class="tbl-scroll">
<table class="tbl">
<thead><tr><th>原料</th><th>単位</th><th class="num">現在在庫</th><th class="num">消費量</th><th class="num">在庫後</th><th><input type="checkbox" id="consume-all-chk" onchange="toggleConsumeAll(this)" title="全選択"> 反映</th></tr></thead>
<tbody>${rows2.map((r,i)=>`
<tr>
<td style="font-weight:800;">${r.ig?escH(r.ig.name):'<span style="color:var(--coral)">削除済み</span>'}</td>
<td>${r.ig?escH(r.ig.unit):''}</td>
<td class="num">${fmtQty(r.currentQty,'')}</td>
<td class="num" style="color:var(--coral);">-${fmtQty(r.amount,'')}</td>
<td class="num ${r.after<0?'bad':''}">${fmtQty(r.after,'')}</td>
<td style="text-align:center;"><input type="checkbox" class="consume-chk" data-ingr="${r.ingrId}" data-amount="${r.amount}" checked></td>
</tr>`).join('')}
</tbody>
</table>
</div>
<div style="margin-top:12px;display:flex;gap:10px;">
<button class="f-btn f-btn-blue" onclick="applyConsumption()">✅ チェックした原料の在庫を減らす</button>
<div style="font-size:11px;color:var(--text2);align-self:center;">※実際の廃棄・試食・賄い分は含まれないため、棚卸しで調整してください</div>
</div>`;
}
function toggleConsumeAll(chk){
document.querySelectorAll('.consume-chk').forEach(c=>c.checked=chk.checked);
}
function applyConsumption(){
const chks=document.querySelectorAll('.consume-chk:checked');
if(!chks.length){showToast('⚠️ 反映する原料を選択してください');return;}
let count=0;
chks.forEach(chk=>{
const ingrId=chk.dataset.ingr;
const amount=parseFloat(chk.dataset.amount||'0');
const s=getIngrStock(ingrId);
const ig=ingredients.find(i=>i.id===ingrId);
const before=s.qty;
s.qty=+Math.max(0,s.qty-amount).toFixed(4);
s.log=s.log||[];
s.log.unshift({date:new Date().toISOString().slice(0,16).replace('T',' '),op:'sub',qty:amount,before,after:s.qty,memo:'売上自動消費計算'});
count++;
});
saveIngrData();
document.getElementById('auto-calc-result').innerHTML=`<div class="alert alert-good"><div class="alert-icon">✅</div><div class="alert-body"><div class="alert-title">${count}種類の原料在庫を更新しました</div></div></div>`;
renderIngrGrid();
updateNavBadge();
showToast(`✅ ${count}種類の原料在庫を反映しました`);
}
// ── 棚卸し ──
function renderStocktakeTable(){
const tbody=document.getElementById('stocktake-tbody');
if(!tbody) return;
const date=document.getElementById('stocktake-date')?.value||todayStr();
if(!ingredients.length){
tbody.innerHTML='<tr><td colspan="8" style="text-align:center;color:var(--text3);padding:24px;">原料が未登録です</td></tr>';
return;
}
tbody.innerHTML=ingredients.map(ig=>{
const s=getIngrStock(ig.id);
const theory=+(s.qty).toFixed(4);
return `<tr class="stocktake-row" data-ingr="${ig.id}">
<td style="text-align:center;"><input type="checkbox" class="stocktake-chk" checked></td>
<td style="font-weight:800;">${escH(ig.name)}</td>
<td>${escH(ig.cat||'—')}</td>
<td><span class="ingr-unit">${escH(ig.unit)}</span></td>
<td class="num">${fmtQty(theory,'')}</td>
<td><input type="number" class="stocktake-actual" min="0" step="0.01" value="${theory}" oninput="updateStocktakeDiff(this,'${ig.id}',${theory},${ig.cost||0})"></td>
<td class="num" id="std-diff-${ig.id}">—</td>
<td class="num" id="std-loss-${ig.id}">—</td>
</tr>`;
}).join('');
}
function updateStocktakeDiff(input, ingrId, theory, cost){
const actual=parseFloat(input.value||'0');
const diff=+(actual-theory).toFixed(4);
const loss=+(-diff*cost).toFixed(0);
const diffEl=document.getElementById('std-diff-'+ingrId);
const lossEl=document.getElementById('std-loss-'+ingrId);
if(diffEl){
diffEl.textContent=(diff>=0?'+':'')+diff;
diffEl.className='num '+(diff<0?'loss-pos':diff>0?'loss-neg':'');
}
if(lossEl){
lossEl.textContent=cost?(loss>=0?'+':'')+'¥'+loss:'—';
lossEl.className='num '+(loss>0?'loss-pos':loss<0?'loss-neg':'');
}
}
function applyStocktake(){
const date=document.getElementById('stocktake-date').value||todayStr();
const rows=document.querySelectorAll('.stocktake-row');
let count=0;
rows.forEach(row=>{
const chk=row.querySelector('.stocktake-chk');
if(!chk||!chk.checked) return;
const ingrId=row.dataset.ingr;
const actualInput=row.querySelector('.stocktake-actual');
if(!actualInput) return;
const actual=parseFloat(actualInput.value||'0');
const s=getIngrStock(ingrId);
const ig=ingredients.find(i=>i.id===ingrId);
const before=s.qty;
s.qty=actual;
s.log=s.log||[];
s.log.unshift({date,op:'set',qty:actual,before,after:actual,memo:'棚卸し確定'});
count++;
});
if(!count){showToast('⚠️ 更新する原料を選択してください');return;}
saveIngrData();
renderIngrGrid();
updateNavBadge();
showToast(`✅ ${count}種類の原料在庫を棚卸し数量で更新しました`);
}
function toggleStocktakeAll(chk){
document.querySelectorAll('.stocktake-chk').forEach(c=>c.checked=chk.checked);
}
function exportStocktakeCSV(){
const date=document.getElementById('stocktake-date').value||todayStr();
const rows=document.querySelectorAll('.stocktake-row');
const hdrs=['原料名','カテゴリ','単位','理論在庫','実在庫','差異','ロス金額'];
const data=[hdrs.join(',')];
rows.forEach(row=>{
const ig=ingredients.find(i=>i.id===row.dataset.ingr);
if(!ig) return;
const s=getIngrStock(ig.id);
const theory=s.qty;
const actual=parseFloat(row.querySelector('.stocktake-actual')?.value||'0');
const diff=+(actual-theory).toFixed(4);
const loss=+(diff*ig.cost).toFixed(0);
data.push([escH(ig.name),escH(ig.cat||''),ig.unit,theory,actual,diff,ig.cost?loss:''].join(','));
});
const blob=new Blob(['\uFEFF'+data.join('\n')],{type:'text/csv;charset=utf-8'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='MISE_stocktake_'+date+'.csv';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),5000);
showToast('📤 棚卸し表をCSV出力しました');
}
// ── ダッシュボードのアラート表示(商品在庫+原料在庫) ──
function renderDashStockAlerts(){
const container=document.getElementById('dash-stock-alerts');
const cnt=document.getElementById('stock-alert-count');
const alertItems=[];
// 商品在庫アラート
(products.length?products:buildProductsFromSales()).forEach(p=>{
const s=stockData[p.id];
if(!s) return;
const isDanger=s.qty<=s.danger;
const isWarn=!isDanger&&s.qty<=s.warn;
if(isDanger||isWarn) alertItems.push({type:isDanger?'bad':'warn',icon:isDanger?'🔴':'🟡',title:`${p.icon||'📦'} ${p.name}(商品在庫)— 残り${s.qty}個`,text:isDanger?`危険ライン(${s.danger}個以下)です。至急補充してください。`:`警告ライン(${s.warn}個以下)です。`});
});
// 原料在庫アラート
ingredients.forEach(ig=>{
const s=ingrStock[ig.id];
if(!s) return;
const qty=s.qty;
const isDanger=qty<=ig.danger;
const isWarn=!isDanger&&qty<=ig.warn;
if(isDanger||isWarn) alertItems.push({type:isDanger?'bad':'warn',icon:isDanger?'🔴':'🟡',title:`🧂 ${ig.name}(原料)— 残り${fmtQty(qty,ig.unit)}`,text:isDanger?`危険ライン(${ig.danger}${ig.unit}以下)に達しています。至急補充してください。`:`警告ライン(${ig.warn}${ig.unit}以下)です。`});
});
if(cnt) cnt.textContent=alertItems.length?`${alertItems.length}件`:'なし';
if(!alertItems.length){
container.innerHTML='<div style="color:var(--text3);text-align:center;padding:20px;font-size:13px;">✅ アラートはありません</div>';
return;
}
container.innerHTML=alertItems.sort((a,b)=>a.type.localeCompare(b.type)).map(a=>`
<div class="alert alert-${a.type}">
<div class="alert-icon">${a.icon}</div>
<div class="alert-body"><div class="alert-title">${a.title}</div><div class="alert-text">${a.text}</div></div>
</div>`).join('');
}
function updateNavBadge(){
const cnt=getStockAlertCount();
const badge=document.getElementById('nb-stock');
if(!badge) return;
const total=cnt.danger+cnt.warn;
badge.style.display=total>0?'inline':'none';
badge.textContent=total;
}
// ── ヘルパー(原料セレクト関連) ──
function rebuildIngrSelects(){
// 入出庫セレクト
rebuildAdjIngrSel();
rebuildRecipeProdList();
rebuildIngrCatList();
}
function rebuildIngrCatList(){
const cats=[...new Set(ingredients.map(ig=>ig.cat).filter(Boolean))];
const dl=document.getElementById('ingr-cat-list');
if(dl) dl.innerHTML=cats.map(c=>`<option value="${escH(c)}">`).join('');
}
function rebuildRecipeProdList(){
const dl=document.getElementById('recipe-prod-list');
if(!dl) return;
const names=[...new Set([
...products.map(p=>p.name),
...buildProductsFromSales().map(p=>p.name),
...recipes.map(r=>r.prodName),
])];
dl.innerHTML=names.map(n=>`<option value="${escH(n)}">`).join('');
}
// ══════════════════════════════════════════════════════════════
// ── 収支・経理 ──
// ══════════════════════════════════════════════════════════════
function financeMonthOffset(delta){
if(delta===0){
const now=new Date();
financeMonth=monthStr(now);
} else {
const d=new Date(financeMonth+'-01');
d.setMonth(d.getMonth()+delta);
financeMonth=monthStr(d);
}
document.getElementById('finance-month').value=financeMonth;
renderFinance();
}
function renderFinance(){
financeMonth=document.getElementById('finance-month').value || financeMonth;
renderPL();
renderDailySalesTable();
renderFinanceChart();
renderExpenseTable();
}
function renderPL(){
const rows=salesForMonth(financeMonth);
const sales=txAmount(rows);
// 消費税を分解(売上に含まれる)
let taxAmt=0;
rows.forEach(r=>{
const taxRate=(r.tax||10)/100;
const preAmt=r.amount/(1+taxRate);
taxAmt+=r.amount-preAmt;
});
const salesExTax=sales-taxAmt;
// 仕入れ
const purRows=purchases.filter(r=>(r.date||'').startsWith(financeMonth));
const purTotal=purRows.reduce((s,r)=>s+(r.total||0),0);
// 経費(仕入れ以外)
const expRows=expenses.filter(r=>(r.date||'').startsWith(financeMonth)&&r.cat!=='仕入れ');
const expBycat={};
expRows.forEach(r=>{ expBycat[r.cat]=(expBycat[r.cat]||0)+(r.amount||0); });
const expTotal=expRows.reduce((s,r)=>s+(r.amount||0),0);
// 粗利・営業利益
const grossProfit=salesExTax-purTotal;
const operatingProfit=grossProfit-expTotal;
const plBody=document.getElementById('pl-body');
plBody.innerHTML=`
<div class="pl-row header"><span>項目</span><span class="num">金額</span></div>
<div class="pl-section">
<div class="pl-row subtotal"><span>📈 売上高(税込)</span><span class="num" style="color:var(--blue)">${yen(sales)}</span></div>
<div class="pl-row item" style="padding-left:24px;"><span style="color:var(--text2);">└ 消費税(概算)</span><span class="num" style="color:var(--text2);">-${yen(taxAmt)}</span></div>
<div class="pl-row item" style="padding-left:24px;"><span style="color:var(--text2);">└ 売上高(税抜)</span><span class="num" style="color:var(--text2);">${yen(salesExTax)}</span></div>
</div>
<div class="pl-section">
<div class="pl-row subtotal"><span>📦 仕入れ原価</span><span class="num" style="color:var(--coral)">-${yen(purTotal)}</span></div>
</div>
<div class="pl-row" style="background:var(--mint-l);border-radius:10px;padding:10px 12px;margin-bottom:12px;">
<span style="font-weight:800;">粗利益</span>
<span class="num" style="color:${grossProfit>=0?'var(--mint)':'var(--coral)'};font-size:16px;">${yen(grossProfit)}</span>
</div>
<div class="pl-section">
<div class="pl-row subtotal"><span>💸 経費合計</span><span class="num" style="color:var(--coral)">-${yen(expTotal)}</span></div>
${Object.entries(expBycat).map(([cat,amt])=>`
<div class="pl-row item" style="padding-left:24px;">
<span style="color:var(--text2);">└ ${escH(cat)}</span>
<span class="num" style="color:var(--text2);">-${yen(amt)}</span>
</div>`).join('')}
</div>
<div class="pl-row total">
<span>💰 営業利益</span>
<span class="num">${yen(operatingProfit)}</span>
</div>
${settings.target_monthly?`
<div style="margin-top:12px;padding:10px 12px;background:var(--blue-l);border-radius:10px;">
<div style="font-size:11px;font-weight:800;color:var(--text2);margin-bottom:6px;">月次目標進捗</div>
<div style="display:flex;justify-content:space-between;font-size:13px;font-weight:800;margin-bottom:4px;">
<span>売上 ${yen(sales)}</span><span>目標 ${yen(settings.target_monthly)}</span>
</div>
<div class="prog-bar" style="height:12px;"><div class="prog-fill" style="width:${Math.min(100,Math.round(sales/settings.target_monthly*100))}%;background:var(--blue)"></div></div>
<div style="font-size:12px;font-weight:700;color:var(--blue);margin-top:4px;text-align:right;">${Math.round(sales/settings.target_monthly*100)}% 達成</div>
</div>`:''}
`;
}
function renderDailySalesTable(){
const tbody=document.getElementById('daily-tbody');
const DOWS=['日','月','火','水','木','金','土'];
// この月の日付ごとに集計
const dayMap={};
salesForMonth(financeMonth).forEach(r=>{
if(!r.date) return;
if(!dayMap[r.date]) dayMap[r.date]={date:r.date,sales:0,tx:new Set(),disc:0};
dayMap[r.date].sales+=(r.amount||0);
dayMap[r.date].tx.add(r.txid||(r.datetime||''));
dayMap[r.date].disc+=Math.round((r.amount/(1-r.discount_pct/100)*r.discount_pct/100)||0);
});
const days=Object.values(dayMap).sort((a,b)=>a.date.localeCompare(b.date));
if(!days.length){tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--text3);padding:20px;">データがありません</td></tr>';return;}
tbody.innerHTML=days.map(d=>{
const dow=new Date(d.date+'T00:00:00').getDay();
const txCnt=d.tx.size;
const atv=txCnt>0?Math.round(d.sales/txCnt):0;
const isHoliday=dow===0||dow===6;
return `<tr>
<td>${d.date}</td>
<td style="${isHoliday?'color:var(--coral)':''}${dow===6?'color:var(--blue)':''}">${DOWS[dow]}</td>
<td class="num">${yen(d.sales)}</td>
<td class="num">${txCnt}</td>
<td class="num">${yen(atv)}</td>
<td class="num" style="color:var(--mint);">-${yen(d.disc)}</td>
</tr>`;
}).join('');
}
function renderFinanceChart(){
// 直近12ヶ月の売上・仕入れ・経費推移
const now=new Date();
const months=[];const salesArr=[];const purArr=[];const expArr=[];
for(let i=11;i>=0;i--){
const d=new Date(now.getFullYear(),now.getMonth()-i,1);
const ym=monthStr(d);
months.push(ym.slice(5)+'月');
salesArr.push(txAmount(salesForMonth(ym)));
purArr.push(purchases.filter(r=>(r.date||'').startsWith(ym)).reduce((s,r)=>s+(r.total||0),0));
expArr.push(expenses.filter(r=>(r.date||'').startsWith(ym)&&r.cat!=='仕入れ').reduce((s,r)=>s+(r.amount||0),0));
}
makeChart('ch-finance',{
type:'bar',
data:{
labels:months,
datasets:[
{label:'売上',data:salesArr,backgroundColor:COLORS[0]+'99',borderColor:COLORS[0],borderWidth:2,borderRadius:4,order:2},
{label:'仕入れ',data:purArr,backgroundColor:COLORS[3]+'88',borderColor:COLORS[3],borderWidth:2,borderRadius:4,order:2},
{label:'経費',data:expArr,backgroundColor:COLORS[2]+'88',borderColor:COLORS[2],borderWidth:2,borderRadius:4,order:2},
]
},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{font:{size:11},boxWidth:10}}},scales:{x:{ticks:{font:{size:10}},grid:{display:false}},y:{beginAtZero:true,ticks:{callback:v=>yen(v/1000)+'k',font:{size:10}}}}}
});
}
function addExpense(){
const date=document.getElementById('exp-date').value||todayStr();
const cat=document.getElementById('exp-cat').value;
const amount=parseInt(document.getElementById('exp-amount').value||'0');
const memo=document.getElementById('exp-memo').value.trim();
if(!amount||amount<=0){showToast('⚠️ 金額を入力してください');return;}
expenses.unshift({id:'E'+Date.now(),date,cat,amount,memo});
localStorage.setItem('mgr_expenses',JSON.stringify(expenses));
document.getElementById('exp-amount').value='';
document.getElementById('exp-memo').value='';
showToast('✅ 経費を登録しました');
renderExpenseTable();
renderPL();
renderFinanceChart();
}
function deleteExpense(id){
expenses=expenses.filter(e=>e.id!==id);
localStorage.setItem('mgr_expenses',JSON.stringify(expenses));
renderExpenseTable();
renderPL();
renderFinanceChart();
}
function renderExpenseTable(){
const tbody=document.getElementById('exp-tbody');
const monthRows=expenses.filter(e=>(e.date||'').startsWith(financeMonth))
.sort((a,b)=>b.date.localeCompare(a.date));
if(!monthRows.length){tbody.innerHTML='<tr><td colspan="5" style="text-align:center;color:var(--text3);padding:20px;">この月の経費はありません</td></tr>';return;}
tbody.innerHTML=monthRows.map(e=>`
<tr>
<td>${e.date}</td>
<td><span class="chip chip-amber">${escH(e.cat)}</span></td>
<td style="color:var(--text2);">${escH(e.memo||'')}</td>
<td class="num bad">-${yen(e.amount)}</td>
<td><button onclick="deleteExpense('${e.id}')" style="border:none;background:none;color:var(--text3);cursor:pointer;font-size:14px;" title="削除">🗑</button></td>
</tr>`).join('');
}
// ══════════════════════════════════════════════════════════════
// ── 売上分析 ──
// ══════════════════════════════════════════════════════════════
function setAnalysisPeriod(period, btn){
analysisPeriod=period;
document.querySelectorAll('.period-bar .period-btn').forEach(b=>b.classList.remove('active'));
if(btn) btn.classList.add('active');
const today=todayStr();
if(period==='week'){
const d=new Date(today);d.setDate(d.getDate()-6);
analysisFrom=d.toISOString().slice(0,10);analysisTo=today;
} else if(period==='month'){
const d=new Date(today);d.setDate(d.getDate()-29);
analysisFrom=d.toISOString().slice(0,10);analysisTo=today;
} else if(period==='quarter'){
const d=new Date(today);d.setDate(d.getDate()-89);
analysisFrom=d.toISOString().slice(0,10);analysisTo=today;
} else if(period==='all'){
analysisFrom='';analysisTo='';
}
if(period!=='custom'){
document.getElementById('an-from').value=analysisFrom;
document.getElementById('an-to').value=analysisTo;
} else {
analysisFrom=document.getElementById('an-from').value;
analysisTo=document.getElementById('an-to').value;
}
renderAnalysis();
}
function setRankMetric(metric, btn){
rankMetric=metric;
document.querySelectorAll('#panel-analysis .s-card .period-btn').forEach(b=>b.classList.remove('active'));
if(btn) btn.classList.add('active');
const rows=filteredSales(analysisFrom,analysisTo);
renderProductRanking(rows);
}
function renderAnalysis(){
const rows=filteredSales(analysisFrom,analysisTo);
renderProductRanking(rows);
renderCatAnalysis(rows);
renderHourlyChart(rows);
renderDowChart(rows);
renderCustCharts(rows);
renderMonthlyChart();
}
function renderProductRanking(rows){
const prodMap={};
rows.forEach(r=>{
const k=r.product||'不明';
if(!prodMap[k]) prodMap[k]={amount:0,qty:0,txids:new Set()};
prodMap[k].amount+=(r.amount||0);
prodMap[k].qty+=(r.quantity||1);
prodMap[k].txids.add(r.txid||(r.datetime||'')+(r.product||''));
});
const entries=Object.entries(prodMap).map(([name,v])=>({name,amount:v.amount,qty:v.qty,tx:v.txids.size}));
entries.sort((a,b)=>b[rankMetric]-a[rankMetric]);
const top=entries.slice(0,10);
const maxVal=top[0]?top[0][rankMetric]:1;
const container=document.getElementById('product-ranking');
const lbl=rankMetric==='amount'?'売上金額':rankMetric==='qty'?'販売個数':'販売回数';
document.getElementById('an-rank-metric-lbl').textContent=lbl;
container.innerHTML=top.map((p,i)=>{
const val=p[rankMetric];
const valStr=rankMetric==='amount'?yen(val):val.toLocaleString()+(rankMetric==='qty'?'個':'回');
return `<div class="rank-item">
<div class="rank-no${i===0?' top1':i===1?' top2':i===2?' top3':''}">${i+1}</div>
<div class="rank-bar-wrap">
<div class="rank-name">${escH(p.name)}</div>
<div class="rank-bar-track"><div class="rank-bar-fill" style="width:${Math.round(val/maxVal*100)}%;background:${COLORS[i%COLORS.length]}"></div></div>
</div>
<div class="rank-val">${valStr}</div>
</div>`;
}).join('');
if(!top.length) container.innerHTML='<div style="color:var(--text3);text-align:center;padding:20px;">データがありません</div>';
}
function renderCatAnalysis(rows){
const catMap={};
rows.forEach(r=>{ const c=r.category||'その他'; catMap[c]=(catMap[c]||0)+(r.amount||0); });
const cats=Object.keys(catMap).sort((a,b)=>catMap[b]-catMap[a]);
makeChart('ch-an-cat',{
type:'doughnut',
data:{labels:cats,datasets:[{data:cats.map(c=>catMap[c]),backgroundColor:COLORS.slice(0,cats.length),borderWidth:2,borderColor:'#fff'}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{font:{size:11},boxWidth:10}}}}
});
}
function renderHourlyChart(rows){
const hours=Array.from({length:24},(_,i)=>i);
const vals=Array(24).fill(0);
rows.forEach(r=>{ const h=parseInt(r.hour||0); if(h>=0&&h<24) vals[h]+=(r.amount||0); });
makeChart('ch-hourly',{
type:'bar',
data:{labels:hours.map(h=>h+'時'),datasets:[{label:'売上',data:vals,backgroundColor:COLORS[1]+'88',borderColor:COLORS[1],borderWidth:2,borderRadius:4}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:{maxTicksLimit:12,font:{size:10}},grid:{display:false}},y:{beginAtZero:true,ticks:{callback:v=>v>=1000?yen(v/1000)+'k':yen(v),font:{size:10}}}}}
});
}
function renderDowChart(rows){
const DOWS=['日','月','火','水','木','金','土'];
const vals=Array(7).fill(0);const cnts=Array(7).fill(0);
rows.forEach(r=>{const d=parseInt(r.dow||0);if(d>=0&&d<7){vals[d]+=(r.amount||0);cnts[d]++;}});
const avgs=vals.map((v,i)=>cnts[i]>0?Math.round(v/cnts[i]):0);
const bgColors=DOWS.map((_,i)=>i===0?COLORS[3]+'88':i===6?COLORS[0]+'88':COLORS[1]+'88');
makeChart('ch-dow',{
type:'bar',
data:{labels:DOWS,datasets:[{label:'平均売上',data:avgs,backgroundColor:bgColors,borderColor:bgColors.map(c=>c.slice(0,-2)),borderWidth:2,borderRadius:6}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{grid:{display:false}},y:{beginAtZero:true,ticks:{callback:v=>v>=1000?yen(v/1000)+'k':yen(v),font:{size:10}}}}}
});
}
function renderCustCharts(rows){
const CUST_LABEL={general:'一般',member:'会員',student:'学生',senior:'シニア',staff:'スタッフ'};
const custMap={};const ageMap={};
rows.forEach(r=>{
const c=CUST_LABEL[r.customer_type]||(r.customer_type||'未記録');
custMap[c]=(custMap[c]||0)+(r.amount||0);
const a=r.age_band||'未記録';
ageMap[a]=(ageMap[a]||0)+(r.amount||0);
});
const custEntries=Object.entries(custMap).sort((a,b)=>b[1]-a[1]);
const ageOrder=['〜19','20-29','30-39','40-49','50-59','60+','未記録'];
const ageEntries=ageOrder.filter(k=>ageMap[k]).map(k=>[k,ageMap[k]]);
makeChart('ch-cust',{
type:'doughnut',
data:{labels:custEntries.map(e=>e[0]),datasets:[{data:custEntries.map(e=>e[1]),backgroundColor:COLORS.slice(0,custEntries.length),borderWidth:2,borderColor:'#fff'}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{font:{size:11},boxWidth:10}}}}
});
makeChart('ch-age',{
type:'bar',
data:{labels:ageEntries.map(e=>e[0]),datasets:[{label:'売上',data:ageEntries.map(e=>e[1]),backgroundColor:COLORS[4]+'99',borderColor:COLORS[4],borderWidth:2,borderRadius:6}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{grid:{display:false}},y:{beginAtZero:true,ticks:{callback:v=>v>=1000?yen(v/1000)+'k':yen(v),font:{size:10}}}}}
});
}
function renderMonthlyChart(){
const now=new Date();
const months=[];const vals=[];
for(let i=11;i>=0;i--){
const d=new Date(now.getFullYear(),now.getMonth()-i,1);
const ym=monthStr(d);
months.push(ym.slice(5)+'月');
vals.push(txAmount(salesForMonth(ym)));
}
makeChart('ch-monthly',{
type:'bar',
data:{labels:months,datasets:[{label:'月売上',data:vals,backgroundColor:COLORS[0]+'88',borderColor:COLORS[0],borderWidth:2,borderRadius:6}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{grid:{display:false}},y:{beginAtZero:true,ticks:{callback:v=>v>=1000?yen(v/1000)+'k':yen(v),font:{size:10}}}}}
});
}
// ══════════════════════════════════════════════════════════════
// ── 仕入れ帳 ──
// ══════════════════════════════════════════════════════════════
function renderPurchasePanel(){
rebuildProductSelects();
renderPurchaseSummary();
renderPurchaseList();
}
function addPurchase(){
const date=document.getElementById('pur-date').value||todayStr();
const supplier=document.getElementById('pur-supplier').value.trim();
const purProdVal=document.getElementById('pur-prod').value; // 'prod:ID' or 'ingr:ID' or ''
const qty=parseFloat(document.getElementById('pur-qty').value||'0');
const unitPrice=parseFloat(document.getElementById('pur-unit-price').value||'0');
const total=parseFloat(document.getElementById('pur-total').value||'0')||(qty*unitPrice);
const memo=document.getElementById('pur-memo').value.trim();
if(!supplier){showToast('⚠️ 仕入れ先を入力してください');return;}
if(!qty||qty<=0){showToast('⚠️ 数量を入力してください');return;}
if(!total||total<=0){showToast('⚠️ 金額を入力してください');return;}
// 品目の種別を判定
let prodId='', ingrId='', prodName='';
if(purProdVal.startsWith('prod:')){
prodId=purProdVal.slice(5);
prodName=products.find(p=>p.id===prodId)?.name||prodId;
} else if(purProdVal.startsWith('ingr:')){
ingrId=purProdVal.slice(5);
prodName=ingredients.find(i=>i.id===ingrId)?.name||ingrId;
}
const pur={
id:'PU'+Date.now(), date, supplier,
product_id:prodId, ingr_id:ingrId, product_name:prodName,
qty, unit_price:unitPrice, total, memo
};
purchases.unshift(pur);
localStorage.setItem('mgr_purchases',JSON.stringify(purchases));
// 商品在庫に反映
if(prodId){
const s=getStockItem(prodId);
const before=s.qty;
s.qty=+(s.qty+qty).toFixed(4);
s.log=s.log||[];
s.log.unshift({date,op:'add',qty,memo:'仕入れ: '+supplier,before,after:s.qty});
localStorage.setItem('mgr_stock',JSON.stringify(stockData));
}
// 原料在庫に反映
if(ingrId){
const s=getIngrStock(ingrId);
const ig=ingredients.find(i=>i.id===ingrId);
const before=s.qty;
s.qty=+(s.qty+qty).toFixed(4);
s.log=s.log||[];
s.log.unshift({date:new Date().toISOString().slice(0,16).replace('T',' '),op:'add',qty,before,after:s.qty,memo:'仕入れ: '+supplier,unitCost:unitPrice});
// 原料マスタの単価を更新(仕入れ単価で上書き)
if(ig&&unitPrice>0) ig.cost=+(total/qty).toFixed(4);
saveIngrData();
}
// 経費に追加
expenses.unshift({id:'E'+Date.now(),date,cat:'仕入れ',amount:total,memo:supplier+(prodName?' '+prodName:'')});
localStorage.setItem('mgr_expenses',JSON.stringify(expenses));
// フォームクリア
['pur-qty','pur-unit-price','pur-total','pur-memo'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
document.getElementById('pur-supplier').value='';
document.getElementById('pur-prod').value='';
const target=ingrId?'原料在庫':prodId?'商品在庫':'経費のみ';
showToast(`✅ 仕入れを登録しました(${target}・経費に反映)`);
renderPurchaseSummary();
renderPurchaseList();
rebuildSupplierList();
// 原料在庫グリッドが表示中なら更新
renderIngrGrid();
updateNavBadge();
}
// 数量×単価の自動計算
document.getElementById('pur-qty').addEventListener('input',calcPurTotal);
document.getElementById('pur-unit-price').addEventListener('input',calcPurTotal);
function calcPurTotal(){
const q=parseInt(document.getElementById('pur-qty').value||'0');
const u=parseInt(document.getElementById('pur-unit-price').value||'0');
if(q>0&&u>0) document.getElementById('pur-total').value=q*u;
}
function deletePurchase(id){
const pur=purchases.find(p=>p.id===id);
if(!pur) return;
// 在庫から戻す確認
purchases=purchases.filter(p=>p.id!==id);
localStorage.setItem('mgr_purchases',JSON.stringify(purchases));
// 対応する経費も削除(同日・仕入れ科目)
expenses=expenses.filter(e=>!(e.cat==='仕入れ'&&e.date===pur.date&&e.amount===pur.total&&e.memo.includes(pur.supplier)));
localStorage.setItem('mgr_expenses',JSON.stringify(expenses));
showToast('🗑 削除しました');
renderPurchaseSummary();
renderPurchaseList();
}
function renderPurchaseSummary(){
const now=new Date();
const thisMonth=monthStr(now);
const purRows=purchases.filter(r=>(r.date||'').startsWith(thisMonth));
const total=purRows.reduce((s,r)=>s+(r.total||0),0);
const supplierMap={};
purRows.forEach(r=>{ supplierMap[r.supplier]=(supplierMap[r.supplier]||0)+(r.total||0); });
const topSuppliers=Object.entries(supplierMap).sort((a,b)=>b[1]-a[1]).slice(0,3);
// 仕入れカテゴリ(商品カテゴリ別)
const catMap={};
purRows.forEach(r=>{
const p=products.find(pr=>pr.id===r.product_id);
const cat=p?.cat||r.product_name||'その他';
catMap[cat]=(catMap[cat]||0)+(r.total||0);
});
const cats=Object.keys(catMap).sort((a,b)=>catMap[b]-catMap[a]);
document.getElementById('pur-summary').innerHTML=`
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px;">
<div style="text-align:center;"><div style="font-size:22px;font-weight:900;font-family:'Nunito',sans-serif;color:var(--blue)">${yen(total)}</div><div style="font-size:11px;color:var(--text2);font-weight:700;">今月の仕入れ合計</div></div>
<div style="text-align:center;"><div style="font-size:22px;font-weight:900;font-family:'Nunito',sans-serif;color:var(--navy)">${purRows.length}</div><div style="font-size:11px;color:var(--text2);font-weight:700;">仕入れ件数</div></div>
</div>
${topSuppliers.map(([sup,amt])=>`
<div style="display:flex;justify-content:space-between;font-size:12px;padding:5px 0;border-bottom:1px solid var(--border);">
<span style="font-weight:700">${escH(sup)}</span><span style="font-family:'Nunito',sans-serif;font-weight:800;color:var(--blue)">${yen(amt)}</span>
</div>`).join('')}
`;
makeChart('ch-pur-cat',{
type:'doughnut',
data:{labels:cats,datasets:[{data:cats.map(c=>catMap[c]),backgroundColor:COLORS.slice(0,cats.length),borderWidth:2,borderColor:'#fff'}]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{font:{size:10},boxWidth:8}}}}
});
}
function renderPurchaseList(){
const filterMonth=document.getElementById('pur-filter-month').value||monthStr(new Date());
const rows=purchases.filter(r=>(r.date||'').startsWith(filterMonth)).sort((a,b)=>b.date.localeCompare(a.date));
const tbody=document.getElementById('pur-tbody');
if(!rows.length){tbody.innerHTML='<tr><td colspan="8" style="text-align:center;color:var(--text3);padding:20px;">この月の仕入れ記録はありません</td></tr>';return;}
tbody.innerHTML=rows.map(r=>`
<tr>
<td>${r.date}</td>
<td style="font-weight:700;">${escH(r.supplier)}</td>
<td>${escH(r.product_name||'—')}</td>
<td class="num">${r.qty.toLocaleString()}</td>
<td class="num">${r.unit_price?yen(r.unit_price):'—'}</td>
<td class="num" style="color:var(--blue);font-weight:800;">${yen(r.total)}</td>
<td style="color:var(--text2);font-size:12px;">${escH(r.memo||'')}</td>
<td><button onclick="deletePurchase('${r.id}')" class="purchase-del" title="削除">🗑</button></td>
</tr>`).join('');
}
function exportPurchaseCSV(){
const filterMonth=document.getElementById('pur-filter-month').value||monthStr(new Date());
const rows=purchases.filter(r=>(r.date||'').startsWith(filterMonth));
if(!rows.length){showToast('⚠️ 仕入れデータがありません');return;}
const hdrs=['date','supplier','product_id','product_name','qty','unit_price','total','memo'];
const csv=[hdrs.join(','),...rows.map(r=>hdrs.map(h=>{
const v=String(r[h]||'');
return v.includes(',')||v.includes('"')?`"${v.replace(/"/g,'""')}"`:`${v}`;
}).join(','))].join('\n');
const blob=new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='MISE_purchase_'+filterMonth+'.csv';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),5000);
showToast('📤 仕入れCSVを出力しました');
}
// ══════════════════════════════════════════════════════════════
// ── 設定 ──
// ══════════════════════════════════════════════════════════════
function saveSettings(){
settings.store=document.getElementById('set-store').value.trim();
settings.type=document.getElementById('set-type').value.trim();
settings.target_monthly=parseInt(document.getElementById('set-target').value||'0')||0;
localStorage.setItem('mgr_settings',JSON.stringify(settings));
document.getElementById('nav-store').textContent=settings.store||'店舗未設定';
showToast('💾 設定を保存しました');
}
function updateDataStatus(){
document.getElementById('ds-sales-cnt').textContent=salesData.length.toLocaleString();
document.getElementById('ds-prod-cnt').textContent=products.length.toLocaleString();
if(salesData.length){
const dates=salesData.map(r=>r.date||'').filter(Boolean).sort();
document.getElementById('ds-date-range').innerHTML=
`<span style="font-size:14px;">${dates[0]}<br>〜${dates[dates.length-1]}</span>`;
} else {
document.getElementById('ds-date-range').textContent='—';
}
}
function clearAllData(){
if(!confirm('売上・仕入れ・経費データをすべて削除します。\n原料マスタ・レシピは保持します。\nこの操作は取り消せません。')) return;
salesData=[];purchases=[];expenses=[];
['mgr_sales','mgr_purchases','mgr_expenses'].forEach(k=>localStorage.setItem(k,'[]'));
updateDataStatus();
renderDashboard();
showToast('🗑 データをリセットしました');
}
function exportAllCSV(){
if(!salesData.length){showToast('⚠️ 売上データがありません');return;}
const hdrs=['txid','datetime','date','hour','dow','month','product_id','product','category','price','quantity','amount','tax','discount_pct','customer_type','age_band','note'];
const csv=[hdrs.join(','),...salesData.map(r=>hdrs.map(h=>{
const v=String(r[h]!==undefined?r[h]:'');
return v.includes(',')||v.includes('"')?`"${v.replace(/"/g,'""')}"`:`${v}`;
}).join(','))].join('\n');
const blob=new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='MISE_all_sales_'+todayStr()+'.csv';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),5000);
showToast('📤 全売上データをCSV出力しました');
}
function exportReport(){
// 現在のパネルに応じてエクスポート
if(document.getElementById('panel-purchase').classList.contains('active')){
exportPurchaseCSV();
} else {
exportAllCSV();
}
}
// ══════════════════════════════════════════════════════════════
// ── 商品セレクト構築 ──
// ══════════════════════════════════════════════════════════════
function rebuildProductSelects(){
const prods=products.length?products:buildProductsFromSales();
const makeOpts=()=>{
return prods.map(p=>`<option value="${escH(p.id)}">${escH(p.icon||'')} ${escH(p.name)} (${escH(p.cat||'')})</option>`).join('');
};
// 商品在庫の商品選択
['stock-prod-sel','alert-prod-sel'].forEach(id=>{
const el=document.getElementById(id);
if(el) el.innerHTML=makeOpts();
});
// 仕入れ帳:商品マスタ + 原料マスタ をグループ分けして選択
const purSel=document.getElementById('pur-prod');
if(purSel){
let html='<option value="">(品目を選択)</option>';
if(prods.length){
html+=`<optgroup label="🛍️ 商品在庫">`;
html+=prods.map(p=>`<option value="prod:${escH(p.id)}">${escH(p.icon||'')} ${escH(p.name)}</option>`).join('');
html+=`</optgroup>`;
}
if(ingredients.length){
html+=`<optgroup label="🧂 原料在庫">`;
html+=ingredients.map(ig=>`<option value="ingr:${escH(ig.id)}">🧂 ${escH(ig.name)} (${escH(ig.unit)})</option>`).join('');
html+=`</optgroup>`;
}
purSel.innerHTML=html;
}
rebuildSupplierList();
}
// 仕入れ品目変更時:単位ヒントを出す
function onPurProdChange(){
const val=document.getElementById('pur-prod')?.value||'';
if(val.startsWith('ingr:')){
const ingrId=val.slice(5);
const ig=ingredients.find(i=>i.id===ingrId);
if(ig&&ig.cost){
document.getElementById('pur-unit-price').value=ig.cost;
}
}
}
function rebuildSupplierList(){
const suppliers=[...new Set(purchases.map(p=>p.supplier).filter(Boolean))];
const dl=document.getElementById('supplier-list');
if(dl) dl.innerHTML=suppliers.map(s=>`<option value="${escH(s)}">`).join('');
}
// ══════════════════════════════════════════════════════════════
// ── Toast ──
// ══════════════════════════════════════════════════════════════
let toastTimer;
function showToast(msg){
const t=document.getElementById('toast');
t.textContent=msg;t.classList.add('show');
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>t.classList.remove('show'),2400);
}
// ── 起動 ──
init();
</script>
</body>
</html>

コメント