小さなお店用のPOSシステム「MisePOS」

【更新履歴】

 ・2026/2/20 バージョン1.0公開。
 ・2026/2/21 バージョン1.1公開。
 ・2026/8/3 バージョン1.2公開。(消費税1%に対応)

画像
レジ画面(商品選択)
画像
決済リスト画面(簡単に削除)
画像
商品の登録画面
画像
レシート読み取り画面

・小規模な小売店向けの売上入力システムです。
・出力された売上データのcsvファイルは、
 分析ツールの「Mise」でも使えます。

・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>MISE POS — レジ入力</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">
<style>
:root {
  --bg:     #fff8f2;
  --bg2:    #fff2e6;
  --surface:#ffffff;
  --peach:  #ff8c69;
  --peach-l:#ffe4d9;
  --coral:  #ff6b6b;
  --mint:   #6bcb8b;
  --mint-l: #d9f5e4;
  --sky:    #5bb8f5;
  --sky-l:  #d9f0ff;
  --lemon:  #ffd166;
  --lemon-l:#fff5cc;
  --purple: #b388f7;
  --purple-l:#ede0ff;
  --gray:   #aaa09a;
  --text:   #3d2c22;
  --text2:  #8a7a70;
  --border: #f0d8c8;
  --shadow: 0 4px 18px rgba(255,100,50,.10);
  --r: 20px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}

body {
  background: var(--bg);
  color: var(--text);
  font-family: 'M PLUS Rounded 1c', sans-serif;
  font-size: 15px;
  height: 100vh;
  margin: 0;
  padding: 0;
  overflow: hidden;
  user-select: none;
  display: flex;
  flex-direction: column;
}

body::before{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' fill='%23ff8c69' opacity='.03'/%3E%3C/svg%3E");pointer-events:none;z-index:999;}

.pos-wrap {
  display: grid;
  grid-template-columns: 1fr 560px;
  flex: 1;
  overflow: hidden;
}
.left{display:flex;flex-direction:column;overflow:hidden;}
.right{background:var(--surface);border-left:2px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}

.right-body{display:grid;grid-template-columns:1fr 210px;flex:1;overflow:hidden;min-height:0;}
.right-items-col{display:flex;flex-direction:column;overflow:hidden;border-right:2px solid var(--border);}
.right-info-col{display:flex;flex-direction:column;overflow-y:auto;background:var(--bg2);}

header{background:linear-gradient(90deg,var(--peach) 0%,var(--coral) 100%);color:#fff;padding:12px 20px;display:flex;align-items:center;gap:14px;flex-shrink:0;box-shadow:0 3px 14px rgba(255,100,50,.25);}
.logo{font-family:'Nunito',sans-serif;font-size:24px;font-weight:900;letter-spacing:.04em;}
.logo span{opacity:.7;font-size:14px;font-weight:700;margin-left:6px;}
.hdr-datetime{margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;background:rgba(255,255,255,.18);padding:5px 14px;border-radius:12px;gap:0;}
.hdr-date{font-family:'M PLUS Rounded 1c',sans-serif;font-size:12px;font-weight:700;color:rgba(255,255,255,.85);line-height:1.3;}
.hdr-clock{font-family:'Nunito',monospace;font-size:22px;font-weight:800;color:#fff;line-height:1.2;}
.hdr-icons{display:flex;gap:8px;}
.hdr-btn{background:rgba(255,255,255,.2);border:none;color:#fff;border-radius:10px;padding:6px 12px;font-size:13px;font-family:inherit;font-weight:700;cursor:pointer;transition:all .15s;}
.hdr-btn:hover{background:rgba(255,255,255,.35);}

.cat-bar{display:flex;gap:8px;padding:12px 16px;overflow-x:auto;background:var(--bg2);border-bottom:2px solid var(--border);flex-shrink:0;}
.cat-bar::-webkit-scrollbar{height:3px;}
.cat-bar::-webkit-scrollbar-thumb{background:var(--peach-l);border-radius:4px;}
.cat-tab{border:2px solid transparent;background:var(--surface);color:var(--text2);border-radius:14px;padding:8px 16px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;transition:all .18s;font-family:inherit;}
.cat-tab.active{background:var(--peach);color:#fff;border-color:var(--coral);box-shadow:0 3px 10px rgba(255,100,50,.3);}
.cat-tab:hover:not(.active){border-color:var(--peach);color:var(--peach);}

.products-area{flex:1;overflow-y:auto;padding:14px 16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:12px;align-content:start;}
.products-area::-webkit-scrollbar{width:5px;}
.products-area::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}

.prod-card{background:var(--surface);border:2.5px solid var(--border);border-radius:var(--r);padding:14px 10px 12px;text-align:center;cursor:pointer;transition:all .15s;position:relative;overflow:hidden;}
.prod-card:hover{transform:translateY(-3px);box-shadow:var(--shadow);}
.prod-card:active{transform:scale(.95);}
.prod-card.sold-out{opacity:.4;pointer-events:none;}
.prod-card.sold-out::after{content:'売り切れ';position:absolute;inset:0;background:rgba(255,255,255,.85);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;color:var(--coral);border-radius:var(--r);}
.prod-icon{font-size:40px;line-height:1;margin-bottom:6px;display:block;}
.prod-img{width:56px;height:56px;object-fit:cover;border-radius:12px;margin-bottom:6px;}
.prod-name{font-size:13px;font-weight:700;color:var(--text);line-height:1.3;margin-bottom:4px;}
.prod-price{font-family:'Nunito',sans-serif;font-size:16px;font-weight:900;color:var(--peach);}
.prod-price small{font-size:11px;color:var(--text2);font-weight:600;}
.prod-badge{position:absolute;top:6px;right:6px;background:var(--coral);color:#fff;font-size:9px;font-weight:800;padding:2px 6px;border-radius:8px;}

.receipt-header{padding:10px 14px 8px;border-bottom:2px solid var(--border);flex-shrink:0;}
.receipt-header h2{font-size:15px;font-weight:800;color:var(--text);margin-bottom:0;}

.customer-bar{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:6px;}
.cust-label{font-size:11px;color:var(--text2);font-weight:700;width:100%;margin-top:6px;}
.cust-btn{border:2px solid var(--border);background:var(--surface);border-radius:10px;padding:5px 7px;font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;font-family:inherit;display:flex;align-items:center;gap:3px;}
.cust-btn.active{border-color:currentColor;background:var(--lemon-l);}
.cust-btn:hover:not(.active){border-color:var(--lemon);background:var(--lemon-l);}
.age-bar{display:flex;gap:4px;flex-wrap:wrap;}
.age-btn{border:2px solid var(--border);background:var(--surface);border-radius:8px;padding:4px 5px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;text-align:center;transition:all .15s;flex:1;min-width:calc(33% - 4px);}
.age-btn.active{background:var(--sky-l);border-color:var(--sky);color:var(--sky);}
.age-btn:hover:not(.active){border-color:var(--sky);background:var(--sky-l);}

.receipt-items{flex:1;overflow-y:scroll;padding:8px 10px;min-height:0;}
.receipt-items::-webkit-scrollbar{width:6px;}
.receipt-items::-webkit-scrollbar-track{background:var(--bg2);border-radius:4px;}
.receipt-items::-webkit-scrollbar-thumb{background:var(--peach);border-radius:4px;}
.receipt-items::-webkit-scrollbar-thumb:hover{background:var(--coral);}
.empty-msg{text-align:center;color:var(--text2);padding:30px 0;font-size:14px;font-weight:700;}
.empty-msg span{font-size:40px;display:block;margin-bottom:8px;}

.receipt-item{display:flex;align-items:center;gap:8px;padding:9px 10px;background:var(--bg);border-radius:14px;margin-bottom:7px;border:2px solid transparent;transition:all .15s;}
.receipt-item:hover{border-color:var(--border);}
.ri-icon{font-size:22px;flex-shrink:0;}
.ri-name{flex:1;font-size:13px;font-weight:700;line-height:1.2;}
.ri-name small{font-size:11px;color:var(--text2);font-weight:600;}
.ri-qty{display:flex;align-items:center;gap:4px;}
.ri-qbtn{width:28px;height:28px;border-radius:10px;border:2px solid var(--border);background:var(--surface);color:var(--text);font-size:16px;font-weight:900;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;transition:all .15s;font-family:'Nunito',sans-serif;}
.ri-qbtn.minus:hover{background:var(--coral);border-color:var(--coral);color:#fff;}
.ri-qbtn.plus:hover{background:var(--mint);border-color:var(--mint);color:#fff;}
.ri-qnum{font-family:'Nunito',sans-serif;font-size:18px;font-weight:900;min-width:26px;text-align:center;}
.ri-amt{font-family:'Nunito',sans-serif;font-size:15px;font-weight:800;color:var(--peach);white-space:nowrap;}
.ri-del{color:var(--text2);background:none;border:none;cursor:pointer;font-size:18px;transition:color .15s;padding:0 2px;}
.ri-del:hover{color:var(--coral);}
.ri-discount{font-size:11px;color:var(--mint);font-weight:700;}

.discount-bar{padding:6px 10px;background:var(--mint-l);border-top:2px dashed var(--border);border-bottom:2px dashed var(--border);display:flex;flex-direction:column;gap:4px;flex-shrink:0;}
.disc-label{font-size:11px;font-weight:700;color:var(--text2);}
.disc-btns{display:flex;gap:4px;flex-wrap:wrap;}
.disc-btn{border:2px solid var(--mint);background:var(--surface);color:var(--mint);border-radius:8px;padding:3px 8px;font-size:11px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .15s;}
.disc-btn.active{background:var(--mint);color:#fff;}
.disc-btn:hover{background:var(--mint);color:#fff;}

.total-area{padding:8px 10px;border-top:2px solid var(--border);flex-shrink:0;}
.total-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;font-size:12px;color:var(--text2);}
.total-row.grand{font-size:18px;font-weight:900;color:var(--text);font-family:'Nunito',sans-serif;margin-top:6px;}
.total-row.grand span:last-child{color:var(--peach);}
.tax-badge{font-size:10px;background:var(--lemon-l);border:1.5px solid var(--lemon);color:var(--text2);padding:1px 6px;border-radius:8px;font-weight:700;}

.action-btns{display:grid;grid-template-columns:1fr;gap:6px;padding:6px 10px 10px;flex-shrink:0;}
.act-btn{border:none;border-radius:14px;padding:12px 8px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .18s;display:flex;align-items:center;justify-content:center;gap:5px;}
.act-cancel{background:var(--peach-l);color:var(--coral);}
.act-cancel:hover{background:var(--coral);color:#fff;}
.act-pay{background:linear-gradient(135deg,var(--mint) 0%,#3db870 100%);color:#fff;box-shadow:0 4px 16px rgba(107,203,139,.35);}
.act-pay:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(107,203,139,.45);}
.act-pay:active{transform:scale(.97);}
.act-pay:disabled{background:var(--border);color:var(--text2);box-shadow:none;transform:none;cursor:not-allowed;}

.modal{position:fixed;inset:0;background:rgba(60,40,30,.5);backdrop-filter:blur(4px);z-index:500;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:28px;padding:28px;max-width:480px;width:90%;box-shadow:0 16px 60px rgba(0,0,0,.2);animation:slideUp .25s;}
@keyframes slideUp{from{transform:translateY(30px);opacity:0;}to{transform:translateY(0);opacity:1;}}
.modal-title{font-size:20px;font-weight:800;margin-bottom:16px;text-align:center;}
.modal-body{font-size:15px;color:var(--text2);text-align:center;margin-bottom:20px;line-height:1.7;}
.modal-btns{display:flex;gap:10px;}
.modal-btns button{flex:1;border:none;border-radius:14px;padding:12px;font-size:15px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .15s;}
.mbtn-cancel{background:var(--bg2);color:var(--text2);}
.mbtn-ok{background:linear-gradient(135deg,var(--peach),var(--coral));color:#fff;}
.mbtn-ok:hover{transform:translateY(-2px);}

.paid-screen{text-align:center;padding:20px;}
.paid-circle{width:80px;height:80px;background:linear-gradient(135deg,var(--mint),#3db870);border-radius:50%;margin:0 auto 16px;display:flex;align-items:center;justify-content:center;font-size:40px;box-shadow:0 6px 24px rgba(107,203,139,.4);animation:pop .4s cubic-bezier(.2,1.4,.4,1);}
@keyframes pop{from{transform:scale(.2);opacity:0;}to{transform:scale(1);opacity:1;}}
.paid-screen h2{font-size:22px;font-weight:900;margin-bottom:6px;}
.paid-amt{font-family:'Nunito',sans-serif;font-size:36px;font-weight:900;color:var(--mint);margin:10px 0;}
.paid-items{background:var(--bg);border-radius:14px;padding:12px 16px;text-align:left;margin:12px 0;font-size:13px;}
.paid-items div{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px solid var(--border);}
.paid-items div:last-child{border-bottom:none;}

.product-mgmt{padding:16px;overflow-y:auto;flex:1;}
.product-mgmt::-webkit-scrollbar{width:6px;}
.product-mgmt::-webkit-scrollbar-track{background:var(--bg2);border-radius:4px;}
.product-mgmt::-webkit-scrollbar-thumb{background:var(--peach);border-radius:4px;}

.field-group{margin-bottom:14px;}
.field-group label{display:block;font-size:12px;font-weight:800;color:var(--text2);margin-bottom:5px;}
.f-input{width:100%;border:2px solid var(--border);border-radius:12px;padding:10px 14px;font-size:15px;font-family:inherit;font-weight:600;color:var(--text);background:var(--bg);outline:none;transition:border-color .15s;}
.f-input:focus{border-color:var(--peach);}
.f-input[readonly]{opacity:.6;cursor:not-allowed;}
.f-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.f-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;}

.ocr-panel{padding:16px;overflow-y:auto;flex:1;}
.ocr-drop{border:3px dashed var(--border);border-radius:20px;padding:30px;text-align:center;cursor:pointer;transition:all .2s;background:var(--bg);}
.ocr-drop:hover,.ocr-drop.drag{border-color:var(--peach);background:var(--peach-l);}
.ocr-drop input{display:none;}
.ocr-queue{margin-top:14px;display:flex;flex-direction:column;gap:8px;max-height:300px;overflow-y:auto;}
.ocr-item{display:flex;align-items:center;gap:10px;background:var(--surface);border:2px solid var(--border);border-radius:14px;padding:10px 14px;}
.ocr-thumb{width:44px;height:44px;object-fit:cover;border-radius:10px;border:2px solid var(--border);}
.ocr-info{flex:1;}
.ocr-name{font-size:13px;font-weight:700;}
.ocr-status{font-size:11px;color:var(--text2);margin-top:2px;}
.ocr-status.processing{color:var(--sky);}
.ocr-status.done{color:var(--mint);}
.ocr-status.err{color:var(--coral);}

.top-mode-bar{display:flex;gap:0;background:var(--bg2);border-bottom:2px solid var(--border);flex-shrink:0;}
.mode-tab{flex:1;padding:10px;text-align:center;font-size:12px;font-weight:800;color:var(--text2);cursor:pointer;border-bottom:3px solid transparent;transition:all .15s;font-family:inherit;background:none;border-left:none;border-right:none;border-top:none;}
.mode-tab.active{color:var(--peach);border-bottom-color:var(--peach);}

.toast{position:fixed;top:70px;left:50%;transform:translateX(-50%);background:var(--text);color:#fff;padding:10px 20px;border-radius:20px;font-size:14px;font-weight:700;z-index:600;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;}
.toast.show{opacity:1;}

.prod-preview{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px;}
.add-prod-btn{background:linear-gradient(135deg,var(--peach),var(--coral));color:#fff;border:none;border-radius:14px;padding:12px;font-size:14px;font-weight:800;cursor:pointer;width:100%;font-family:inherit;margin-top:10px;}

.right-panel{display:none;flex-direction:column;height:100%;overflow:hidden;}
.right-panel.active{display:flex;}

.note-area{padding:0 10px 6px;flex-shrink:0;}
.note-input{width:100%;border:2px solid var(--border);border-radius:10px;padding:6px 10px;font-size:12px;font-family:inherit;color:var(--text);background:var(--surface);outline:none;resize:none;height:46px;}
.note-input:focus{border-color:var(--sky);}

.history-panel{padding:0;overflow:hidden;flex:1;display:flex;flex-direction:column;}
.history-toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--bg2);border-bottom:2px solid var(--border);flex-shrink:0;flex-wrap:wrap;}
.history-toolbar .ht-label{font-size:12px;font-weight:700;color:var(--text2);white-space:nowrap;}
.history-date-input{border:2px solid var(--border);border-radius:10px;padding:5px 10px;font-size:13px;font-family:inherit;font-weight:600;color:var(--text);background:var(--surface);outline:none;cursor:pointer;}
.history-date-input:focus{border-color:var(--peach);}
.history-toolbar .ht-btn{border:2px solid var(--border);background:var(--surface);border-radius:10px;padding:5px 10px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;color:var(--text2);white-space:nowrap;transition:all .15s;}
.history-toolbar .ht-btn:hover{border-color:var(--peach);color:var(--peach);}
.history-toolbar .ht-btn.danger{border-color:var(--coral);color:var(--coral);}
.history-toolbar .ht-btn.danger:hover{background:var(--coral);color:#fff;}
.history-toolbar .ht-btn.primary{border-color:var(--mint);color:var(--mint);}
.history-toolbar .ht-btn.primary:hover{background:var(--mint);color:#fff;}
.history-summary{padding:6px 14px;background:var(--lemon-l);border-bottom:2px solid var(--border);font-size:12px;font-weight:700;color:var(--text2);flex-shrink:0;display:flex;gap:14px;flex-wrap:wrap;}
.history-summary span{color:var(--text);}
.history-list{flex:1;overflow-y:scroll;padding:10px 12px;}
.history-list::-webkit-scrollbar{width:6px;}
.history-list::-webkit-scrollbar-track{background:var(--bg2);border-radius:4px;}
.history-list::-webkit-scrollbar-thumb{background:var(--peach);border-radius:4px;}
.history-list::-webkit-scrollbar-thumb:hover{background:var(--coral);}
.history-tx{background:var(--surface);border:2px solid var(--border);border-radius:14px;margin-bottom:8px;overflow:hidden;transition:all .15s;}
.history-tx.selected{border-color:var(--peach);background:var(--peach-l);}
.history-tx-header{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;}
.history-tx-header:hover{background:rgba(255,140,105,.06);}
.htx-check{width:20px;height:20px;border-radius:6px;border:2px solid var(--border);background:var(--bg);cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:12px;transition:all .15s;}
.htx-check.checked{background:var(--peach);border-color:var(--coral);color:#fff;}
.htx-id{font-family:'Nunito',sans-serif;font-size:13px;font-weight:900;color:var(--peach);min-width:70px;}
.htx-time{font-size:12px;color:var(--text2);font-weight:700;}
.htx-cust{font-size:11px;color:var(--sky);font-weight:700;background:var(--sky-l);border-radius:8px;padding:2px 7px;}
.htx-total{font-family:'Nunito',sans-serif;font-size:16px;font-weight:900;color:var(--text);margin-left:auto;}
.htx-expand{font-size:16px;color:var(--text2);transition:transform .15s;flex-shrink:0;}
.htx-expand.open{transform:rotate(180deg);}
.history-tx-body{display:none;padding:0 12px 10px;border-top:1px solid var(--border);}
.history-tx-body.open{display:block;}
.htx-item{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:12px;border-bottom:1px dotted var(--border);}
.htx-item:last-child{border-bottom:none;}
.htx-item-name{color:var(--text);font-weight:700;}
.htx-item-detail{color:var(--text2);}
.htx-item-amt{font-family:'Nunito',sans-serif;font-weight:800;color:var(--peach);}
.htx-note{font-size:11px;color:var(--text2);margin-top:6px;padding:5px 8px;background:var(--bg2);border-radius:8px;}
.htx-disc{font-size:11px;color:var(--mint);font-weight:700;margin-top:4px;}
.history-empty{text-align:center;color:var(--text2);padding:40px 20px;font-size:14px;font-weight:700;}
.history-empty span{font-size:40px;display:block;margin-bottom:8px;}

@media (max-width:900px){
  .pos-wrap{display:flex; flex-direction:column;}
  .right{height:55vh; border-left:none; border-top:2px solid var(--border); flex-shrink:0;}
  .left{flex:1;}
  .right-body{grid-template-columns:1fr 180px;}
}
@media (max-width:600px){
  .right-body{grid-template-columns:1fr;}
  .right-info-col{display:none;}
}
</style>
</head>
<body>
<header>
  <div class="logo">🛒 MISE POS<span>入力ツール</span></div>
  <div class="hdr-datetime" id="hdr-datetime">
    <div class="hdr-date" id="hdr-date">----年--月--日(-)</div>
    <div class="hdr-clock" id="clock">--:--</div>
  </div>
  <div class="hdr-icons">
    <button class="hdr-btn" onclick="openSettings()">⚙️ 設定</button>
    <button class="hdr-btn" onclick="exportCSV()">📤 CSV出力</button>
  </div>
</header>

<div class="pos-wrap">

  <div class="left">
    <div class="top-mode-bar">
      <button class="mode-tab active" onclick="setMode('pos',this)">🛒 レジ</button>
      <button class="mode-tab" onclick="setMode('history',this)">📋 決済リスト</button>
      <button class="mode-tab" onclick="setMode('products',this)">📦 商品登録</button>
      <button class="mode-tab" onclick="setMode('ocr',this)">📸 レシートOCR</button>
    </div>

    <div id="panel-pos" style="display:flex;flex-direction:column;flex:1;overflow:hidden;">
      <div class="cat-bar" id="cat-bar">
        <button class="cat-tab active" data-cat="all" onclick="filterCat('all',this)">🏷️ すべて</button>
      </div>
      <div class="products-area" id="products-grid"></div>
    </div>

    <div id="panel-products" style="display:none;flex:1;overflow:hidden;">
      <div class="product-mgmt" id="product-mgmt">
        <h3 style="font-size:16px;font-weight:800;margin-bottom:14px;" id="pm-heading">📦 商品を登録・編集</h3>
        <div class="f-row">
          <div class="field-group">
            <label>商品名</label>
            <input class="f-input" id="pm-name" placeholder="例: カフェラテ">
          </div>
          <div class="field-group">
            <label>カテゴリ</label>
            <input class="f-input" id="pm-cat" placeholder="例: ドリンク" list="cat-datalist">
            <datalist id="cat-datalist"></datalist>
          </div>
        </div>
        <div class="f-row">
          <div class="field-group">
            <label>価格(税抜)</label>
            <input class="f-input" type="number" id="pm-price" placeholder="500" min="0" step="1">
          </div>
          <div class="field-group">
            <label>消費税(%)</label>
            <input class="f-input" type="number" id="pm-tax" value="10" min="0" max="100" step="0.1" list="tax-presets" placeholder="例: 10 / 8 / 1 / 0">
            <datalist id="tax-presets">
              <option value="10">10%(標準)</option>
              <option value="8">8%(軽減)</option>
              <option value="1">1%</option>
              <option value="0">0%(非課税)</option>
            </datalist>
          </div>
        </div>
        <div class="f-row">
          <div class="field-group">
            <label>アイコン(絵文字)</label>
            <input class="f-input" id="pm-icon" placeholder="☕">
          </div>
          <div class="field-group">
            <label>商品ID <span id="pm-id-note" style="font-weight:600;color:var(--text2);display:none;">(編集中は変更不可)</span></label>
            <input class="f-input" id="pm-id" placeholder="自動生成">
          </div>
        </div>
        <div class="f-row">
          <div class="field-group">
            <label>バーコード / SKU</label>
            <input class="f-input" id="pm-barcode" placeholder="省略可">
          </div>
          <div class="field-group">
            <label>バッジ表示(任意)</label>
            <input class="f-input" id="pm-badge" placeholder="例: 新商品 / 数量限定">
          </div>
        </div>
        <div class="field-group">
          <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
            <input type="checkbox" id="pm-soldout" style="width:18px;height:18px;cursor:pointer;">
            🚫 売り切れとして登録する
          </label>
        </div>
        <div class="field-group">
          <label>色テーマ</label>
          <div style="display:flex;gap:8px;flex-wrap:wrap;" id="color-picker">
            <div class="color-swatch" data-color="#ffe4d9" style="width:32px;height:32px;background:#ffe4d9;border-radius:10px;border:3px solid transparent;cursor:pointer;" onclick="selectColor(this)"></div>
            <div class="color-swatch" data-color="#d9f5e4" style="width:32px;height:32px;background:#d9f5e4;border-radius:10px;border:3px solid transparent;cursor:pointer;" onclick="selectColor(this)"></div>
            <div class="color-swatch" data-color="#d9f0ff" style="width:32px;height:32px;background:#d9f0ff;border-radius:10px;border:3px solid transparent;cursor:pointer;" onclick="selectColor(this)"></div>
            <div class="color-swatch" data-color="#fff5cc" style="width:32px;height:32px;background:#fff5cc;border-radius:10px;border:3px solid transparent;cursor:pointer;" onclick="selectColor(this)"></div>
            <div class="color-swatch" data-color="#ede0ff" style="width:32px;height:32px;background:#ede0ff;border-radius:10px;border:3px solid transparent;cursor:pointer;" onclick="selectColor(this)"></div>
            <div class="color-swatch" data-color="#ffd9d9" style="width:32px;height:32px;background:#ffd9d9;border-radius:10px;border:3px solid transparent;cursor:pointer;" onclick="selectColor(this)"></div>
          </div>
        </div>
        <button class="add-prod-btn" onclick="saveProduct()" id="pm-save-btn">✅ 商品を保存</button>
        <button class="add-prod-btn" style="background:var(--bg2);color:var(--text2);display:none;margin-top:6px;" id="pm-cancel-edit-btn" onclick="cancelEditProduct()">✖ 編集をキャンセルして新規登録に戻す</button>
        <div id="pm-feedback" style="text-align:center;margin-top:10px;font-size:13px;font-weight:700;color:var(--mint);min-height:20px;"></div>

        <h3 style="font-size:15px;font-weight:800;margin:20px 0 12px;">登録済み商品一覧</h3>
        <div id="pm-list" style="display:flex;flex-direction:column;gap:8px;padding-bottom:30px;"></div>
      </div>
    </div>

    <div id="panel-ocr" style="display:none;flex:1;overflow:hidden;">
      <div class="ocr-panel">
        <h3 style="font-size:16px;font-weight:800;margin-bottom:12px;">📸 レシートOCR入力</h3>
        <p style="font-size:13px;color:var(--text2);margin-bottom:14px;line-height:1.7;">レシートの写真・スキャンをドロップすると、ローカルLLM/OCRで読み取ります。まとめて複数ファイルを処理できます。</p>
        <label class="ocr-drop" id="ocr-drop">
          <input type="file" multiple accept="image/*,.pdf" onchange="handleOCRFiles(this.files)" id="ocr-file">
          <div style="font-size:40px;margin-bottom:10px;">📄</div>
          <div style="font-size:15px;font-weight:800;margin-bottom:4px;">ここにドロップ</div>
          <div style="font-size:13px;color:var(--text2);">または クリックして選択</div>
          <div style="font-size:11px;color:var(--text2);margin-top:8px;">対応: JPG / PNG / PDF</div>
        </label>

        <div style="margin-top:14px;display:flex;gap:10px;">
          <select class="f-input" id="ocr-endpoint" style="flex:1;font-size:13px;padding:8px;" onchange="onOcrEndpointChange()">
            <option value="local">ローカルLLM(Ollama / llama.cpp)</option>
            <option value="custom">カスタムエンドポイント</option>
          </select>
          <input class="f-input" id="ocr-url" placeholder="http://localhost:11434/api/generate" style="flex:2;font-size:12px;padding:8px;">
        </div>

        <div class="ocr-queue" id="ocr-queue">
          <div style="text-align:center;color:var(--text2);font-size:13px;padding:16px;">レシートをドロップするとここに表示されます</div>
        </div>

        <button class="add-prod-btn" style="margin-top:14px;" onclick="processOCRQueue()">🔍 OCR処理を開始</button>
      </div>
    </div>

    <div id="panel-history" style="display:none;flex:1;overflow:hidden;flex-direction:column;">
      <div class="history-panel">
        <div class="history-toolbar">
          <span class="ht-label">📅 日付</span>
          <input type="date" class="history-date-input" id="history-date" onchange="onHistoryDateChange()">
          <button class="ht-btn" onclick="historyDateOffset(-1)">◀ 前日</button>
          <button class="ht-btn" onclick="historyDateOffset(1)">翌日 ▶</button>
          <button class="ht-btn" onclick="setHistoryDateToday()">今日</button>
          <button class="ht-btn primary" onclick="exportHistoryCSV()" title="表示中の日のCSVを保存">📥 CSV保存</button>
          <button class="ht-btn primary" onclick="document.getElementById('history-csv-input').click()" title="CSVを読み込んで復元">📂 CSV読込</button>
          <input type="file" id="history-csv-input" accept=".csv" style="display:none" onchange="importHistoryCSV(this)">
          <button class="ht-btn danger" id="history-del-btn" onclick="deleteSelectedTx()" style="display:none;">🗑 選択を削除</button>
        </div>
        <div class="history-summary" id="history-summary">
          <span>データなし</span>
        </div>
        <div class="history-list" id="history-list">
          <div class="history-empty"><span>📋</span>決済データがありません</div>
        </div>
      </div>
    </div>
  </div>

  <div class="right">
    <div class="receipt-header">
      <h2>🧾 レシート <span style="font-size:12px;font-weight:600;color:var(--text2);" id="txid-label"></span></h2>
    </div>

    <div class="right-body">

      <div class="right-items-col">
        <div class="receipt-items" id="receipt-items">
          <div class="empty-msg"><span>🛍️</span>商品をタップして追加</div>
        </div>
      </div>

      <div class="right-info-col">

        <div style="padding:8px 10px;border-bottom:2px solid var(--border);flex-shrink:0;">
          <div class="cust-label">👤 顧客種別</div>
          <div class="customer-bar" id="cust-bar"></div>
          <div class="cust-label">🎂 年齢層</div>
          <div class="age-bar" id="age-bar"></div>
        </div>

        <div class="note-area" style="padding-top:8px;">
          <div style="font-size:11px;color:var(--text2);font-weight:700;margin-bottom:4px;">📝 メモ</div>
          <textarea class="note-input" id="tx-note" placeholder="特記事項..."></textarea>
        </div>

        <div class="discount-bar">
          <span class="disc-label">💚 割引</span>
          <div class="disc-btns">
            <button class="disc-btn" data-disc="0" onclick="setDiscount(0,this)">なし</button>
            <button class="disc-btn" data-disc="5" onclick="setDiscount(5,this)">5%</button>
            <button class="disc-btn" data-disc="10" onclick="setDiscount(10,this)">10%</button>
            <button class="disc-btn" data-disc="15" onclick="setDiscount(15,this)">15%</button>
            <button class="disc-btn" data-disc="-1" onclick="openCustomDiscount()">任意</button>
          </div>
        </div>

        <div class="total-area">
          <div class="total-row"><span>小計</span><span id="subtotal"0</span></div>
          <div class="total-row"><span>割引 <span class="tax-badge" id="disc-badge">0%</span></span><span id="disc-amt" style="color:var(--mint);">-¥0</span></div>
          <div class="total-row"><span>消費税</span><span id="tax-amt"0</span></div>
          <div class="total-row grand"><span>合計</span><span id="grand-total"0</span></div>
        </div>

        <div class="action-btns">
          <button class="act-btn act-pay" id="pay-btn" disabled onclick="confirmPay()">✅ 決済する</button>
          <button class="act-btn act-cancel" onclick="confirmCancel()">❌ キャンセル</button>
        </div>

      </div></div></div>
</div>

<div class="modal hidden" id="modal">
  <div class="modal-box" id="modal-box">
    <div class="modal-title" id="modal-title"></div>
    <div class="modal-body" id="modal-body"></div>
    <div class="modal-btns" id="modal-btns"></div>
  </div>
</div>

<div class="toast" id="toast"></div>

<script>
// ═══════════════════════════════════════════════════════
// ─── IndexedDB Utils (ファイルハンドル永続化用) ────────
// ═══════════════════════════════════════════════════════
const DB_NAME = 'mise_pos_db';
const STORE_NAME = 'handles';

function initDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = (e) => {
      e.target.result.createObjectStore(STORE_NAME);
    };
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = () => reject(request.error);
  });
}

async function saveHandle(handle) {
  const db = await initDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    tx.objectStore(STORE_NAME).put(handle, 'csvDirHandle');
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function loadHandle() {
  const db = await initDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readonly');
    const request = tx.objectStore(STORE_NAME).get('csvDirHandle');
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(tx.error);
  });
}

async function verifyPermission(fileHandle, readWrite) {
  const options = {};
  if (readWrite) {
    options.mode = 'readwrite';
  }
  if ((await fileHandle.queryPermission(options)) === 'granted') {
    return true;
  }
  return false;
}

// ═══════════════════════════════════════════════════════
// ─── 音声 ───────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let actx = null;
function getCtx(){if(!actx)actx=new AudioCtx();return actx;}

function beep(freq=880, dur=80, type='sine', vol=0.3, delay=0){
  try{
    const ctx=getCtx();
    const o=ctx.createOscillator();
    const g=ctx.createGain();
    o.connect(g); g.connect(ctx.destination);
    o.type=type; o.frequency.value=freq;
    g.gain.setValueAtTime(0,ctx.currentTime+delay);
    g.gain.linearRampToValueAtTime(vol,ctx.currentTime+delay+0.01);
    g.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+delay+dur/1000);
    o.start(ctx.currentTime+delay);
    o.stop(ctx.currentTime+delay+dur/1000+0.05);
  }catch(e){}
}
function sfxAdd(){beep(880,70,'sine');}
function sfxRemove(){beep(440,80,'sine');}
function sfxCancel(){beep(330,120,'sawtooth',0.15);}
function sfxPay(){beep(660,60,'sine');setTimeout(()=>beep(880,80,'sine'),80);setTimeout(()=>beep(1100,150,'sine'),170);}
function sfxError(){beep(220,200,'sawtooth',0.2);}
function sfxSave(){beep(700,60,'sine');setTimeout(()=>beep(900,80,'sine'),90);}

// ═══════════════════════════════════════════════════════
// ─── データ ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
let products = JSON.parse(localStorage.getItem('mise_products') || 'null') || [
  {id:'P001',name:'カフェラテ',cat:'ドリンク',price:450,tax:10,icon:'☕',color:'#ffe4d9'},
  {id:'P002',name:'抹茶ラテ',cat:'ドリンク',price:480,tax:10,icon:'🍵',color:'#d9f5e4'},
  {id:'P003',name:'クロワッサン',cat:'パン',price:220,tax:8,icon:'🥐',color:'#fff5cc'},
  {id:'P004',name:'チーズケーキ',cat:'スイーツ',price:380,tax:8,icon:'🍰',color:'#ede0ff'},
  {id:'P005',name:'ブレンドコーヒー',cat:'ドリンク',price:350,tax:10,icon:'☕',color:'#d9f0ff'},
  {id:'P006',name:'ホットサンド',cat:'フード',price:600,tax:8,icon:'🥪',color:'#ffd9d9'},
  {id:'P007',name:'レモネード',cat:'ドリンク',price:400,tax:10,icon:'🍋',color:'#fff5cc'},
  {id:'P008',name:'ガトーショコラ',cat:'スイーツ',price:420,tax:8,icon:'🍫',color:'#ede0ff'},
  {id:'P009',name:'オレンジジュース',cat:'ドリンク',price:380,tax:10,icon:'🍊',color:'#ffe4d9'},
  {id:'P010',name:'マフィン',cat:'パン',price:280,tax:8,icon:'🧁',color:'#ffd9d9'},
];
let sales = JSON.parse(localStorage.getItem('mise_sales') || '[]');
let cart = [];
let discountPct = 0;
let selectedCustomer = null;
let selectedAge = null;
let currentCat = 'all';
const DEFAULT_COLOR = '#ffe4d9';
let selectedColor = DEFAULT_COLOR;
let editingProductId = null;
let ocrQueue = [];
let txCounter = parseInt(localStorage.getItem('mise_txcounter')||'1');

let csvDirHandle = null;

let selectedTxIds  = new Set();
let expandedTxIds  = new Set();
let historyViewDate = '';

const customerTypes = [
  {key:'general', label:'一般', icon:'👤'},
  {key:'member',  label:'会員', icon:'🌟'},
  {key:'student', label:'学生', icon:'🎓'},
  {key:'senior',  label:'シニア', icon:'👴'},
  {key:'staff',   label:'スタッフ', icon:'🏷️'},
];
const ageBands = ['〜19','20-29','30-39','40-49','50-59','60+'];

// ═══════════════════════════════════════════════════════
// ─── 初期化 ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
async function init(){
  historyViewDate=todayStr();
  renderClock();
  setInterval(renderClock,1000);
  renderCatBar();
  renderProducts();
  renderCustomerBar();
  renderAgeBar();
  renderCart();
  updateTxLabel();
  highlightSwatch(selectedColor);
  onOcrEndpointChange();
  applyStoredSettings();
  
  if('showDirectoryPicker' in window){
    const storedHandle = await loadHandle().catch(()=>null);
    if(storedHandle){
      try {
        const hasPerm = await verifyPermission(storedHandle, true);
        if(hasPerm){
          csvDirHandle = storedHandle;
          localStorage.setItem('mise_csv_folder_name', csvDirHandle.name);
          await loadSettingFromFolder();
          const disp = document.getElementById('set-folder-display');
          if(disp){
            disp.textContent = '📂 ' + csvDirHandle.name;
            disp.style.color = 'var(--mint)';
          }
        } else {
          showFolderBanner(storedHandle.name);
        }
      } catch(e) {
        showFolderBanner(storedHandle.name || localStorage.getItem('mise_csv_folder_name'));
      }
    } else {
      const lastName = localStorage.getItem('mise_csv_folder_name');
      if(lastName){
        showFolderBanner(lastName);
      }
    }
  }
}

function renderClock(){
  const n=new Date();
  document.getElementById('clock').textContent=
    String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0');
  const DOW_JP=['日','月','火','水','木','金','土'];
  document.getElementById('hdr-date').textContent=
    n.getFullYear()+'年'+(n.getMonth()+1)+'月'+n.getDate()+'日('+DOW_JP[n.getDay()]+')';
}

function updateTxLabel(){
  document.getElementById('txid-label').textContent='#'+String(txCounter).padStart(4,'0');
}

function renderCatBar(){
  const cats=['all',...new Set(products.map(p=>p.cat))];
  const bar=document.getElementById('cat-bar');
  bar.innerHTML=cats.map(c=>`<button class="cat-tab${c===currentCat?' active':''}" onclick="filterCat('${c}',this)">
    ${c==='all'?'🏷️ すべて':c}
  </button>`).join('');
  const dl=document.getElementById('cat-datalist');
  dl.innerHTML=cats.filter(c=>c!=='all').map(c=>`<option value="${c}">`).join('');
}

function filterCat(cat,btn){
  currentCat=cat;
  document.querySelectorAll('.cat-tab').forEach(b=>b.classList.remove('active'));
  btn.classList.add('active');
  renderProducts();
}

function renderProducts(){
  const filtered = currentCat==='all' ? products : products.filter(p=>p.cat===currentCat);
  const grid=document.getElementById('products-grid');
  if(!filtered.length){grid.innerHTML='<div style="color:var(--text2);text-align:center;padding:40px;grid-column:1/-1;font-weight:700;">商品がありません</div>';return;}
  grid.innerHTML=filtered.map(p=>`
    <div class="prod-card${p.soldOut?' sold-out':''}" style="background:${p.color||'#fff'}" onclick="addToCart('${p.id}')">
      ${p.badge?`<div class="prod-badge">${escHtml(p.badge)}</div>`:''}
      <span class="prod-icon">${p.icon||'📦'}</span>
      <div class="prod-name">${escHtml(p.name)}</div>
      <div class="prod-price">¥${Math.round(p.price*(1+p.tax/100)).toLocaleString()} <small>税込</small></div>
    </div>`).join('');
}

function renderCustomerBar(){
  const bar=document.getElementById('cust-bar');
  bar.innerHTML=customerTypes.map(c=>`
    <button class="cust-btn${selectedCustomer===c.key?' active':''}" onclick="selectCustomer('${c.key}',this)">
      ${c.icon} ${c.label}
    </button>`).join('');
}
function selectCustomer(key,btn){
  selectedCustomer = selectedCustomer===key ? null : key;
  beep(660,40,'sine',0.2);
  renderCustomerBar();
}

function renderAgeBar(){
  const bar=document.getElementById('age-bar');
  bar.innerHTML=ageBands.map(a=>`
    <button class="age-btn${selectedAge===a?' active':''}" onclick="selectAge('${a}',this)">
      ${a}
    </button>`).join('');
}
function selectAge(age,btn){
  selectedAge = selectedAge===age ? null : age;
  beep(660,40,'sine',0.2);
  renderAgeBar();
}

// ═══════════════════════════════════════════════════════
// ─── カート操作 ─────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function addToCart(pid){
  const prod = products.find(p=>p.id===pid);
  if(!prod) return;
  if(prod.soldOut){sfxError();showToast('⚠️ この商品は売り切れです');return;}
  const existing = cart.find(i=>i.pid===pid);
  if(existing){existing.qty++;}
  else{cart.push({pid,qty:1,discount:0});}
  sfxAdd();
  renderCart();
  showToast(`${prod.icon||'📦'} ${prod.name} を追加`);
}

function changeQty(pid, delta){
  const item = cart.find(i=>i.pid===pid);
  if(!item) return;
  item.qty += delta;
  if(item.qty <= 0){
    cart = cart.filter(i=>i.pid!==pid);
    sfxRemove();
  } else {
    delta>0 ? sfxAdd() : sfxRemove();
  }
  renderCart();
}

function removeFromCart(pid){
  cart = cart.filter(i=>i.pid!==pid);
  sfxRemove();
  renderCart();
}

function renderCart(){
  const area=document.getElementById('receipt-items');
  if(!cart.length){
    area.innerHTML='<div class="empty-msg"><span>🛍️</span>商品をタップして追加</div>';
    document.getElementById('pay-btn').disabled=true;
  } else {
    area.innerHTML=cart.map(item=>{
      const p=products.find(pr=>pr.id===item.pid);
      if(!p) return '';
      const unitTax = Math.round(p.price*(p.tax/100));
      const unitFull = p.price + unitTax;
      const total = unitFull * item.qty;
      return `<div class="receipt-item">
        <span class="ri-icon">${p.icon||'📦'}</span>
        <div class="ri-name">${escHtml(p.name)}<br><small>¥${unitFull.toLocaleString()} × ${item.qty}</small></div>
        <div class="ri-qty">
          <button class="ri-qbtn minus" onclick="changeQty('${p.id}',-1)">−</button>
          <span class="ri-qnum">${item.qty}</span>
          <button class="ri-qbtn plus" onclick="changeQty('${p.id}',1)">+</button>
        </div>
        <div class="ri-amt">¥${total.toLocaleString()}</div>
        <button class="ri-del" onclick="removeFromCart('${p.id}')" title="削除">🗑</button>
      </div>`;
    }).join('');
    document.getElementById('pay-btn').disabled=false;
  }
  updateTotals();
}

// ─── 合計計算 ─────────────────────────────────────────────
function calcTotals(){
  let subtotal=0, taxTotal=0;
  cart.forEach(item=>{
    const p=products.find(pr=>pr.id===item.pid);
    if(!p) return;
    subtotal += p.price * item.qty;
    taxTotal += Math.round(p.price*(p.tax/100)) * item.qty;
  });
  const subtotalFull = subtotal + taxTotal;
  const discAmt = Math.round(subtotalFull * discountPct / 100);
  const grand = subtotalFull - discAmt;
  return {subtotalFull, discAmt, taxTotal, grand};
}

function updateTotals(){
  const {subtotalFull, discAmt, taxTotal, grand} = calcTotals();
  document.getElementById('subtotal').textContent = '¥'+subtotalFull.toLocaleString();
  document.getElementById('disc-amt').textContent = '-¥'+discAmt.toLocaleString();
  document.getElementById('disc-badge').textContent = discountPct+'%';
  document.getElementById('tax-amt').textContent = '¥'+taxTotal.toLocaleString();
  document.getElementById('grand-total').textContent = '¥'+grand.toLocaleString();
}

function setDiscount(pct, btn){
  if(pct===-1) return openCustomDiscount();
  discountPct = pct;
  document.querySelectorAll('.disc-btn').forEach(b=>b.classList.remove('active'));
  btn.classList.add('active');
  const custom=document.querySelector('.disc-btn[data-disc="-1"]');
  if(custom) custom.textContent='任意';
  beep(700,50,'sine',0.2);
  updateTotals();
}

function openCustomDiscount(){
  showModal('任意割引を入力','',`
    <div style="margin-bottom:16px;">
      <input id="custom-disc-input" class="f-input" type="number" min="0" max="100" value="${discountPct>0?discountPct:''}" placeholder="割引率 %" style="font-size:24px;text-align:center;padding:14px;">
    </div>
  `,[
    {label:'キャンセル',cls:'mbtn-cancel',action:closeModal},
    {label:'適用',cls:'mbtn-ok',action:()=>{
      const v=parseInt(document.getElementById('custom-disc-input').value||'0');
      if(v<0||v>100){sfxError();showToast('0〜100の値を入力してください');return;}
      discountPct=v;
      document.querySelectorAll('.disc-btn').forEach(b=>b.classList.remove('active'));
      const custom=document.querySelector('.disc-btn[data-disc="-1"]');
      if(custom){custom.classList.add('active');custom.textContent=v+'%';}
      beep(700,50,'sine',0.2);
      updateTotals();
      closeModal();
    }},
  ]);
}

// ═══════════════════════════════════════════════════════
// ─── 決済・ローカルタイム取得 ─────────────────────────
// ═══════════════════════════════════════════════════════

function getLocalDatetime() {
  const now = new Date();
  const yyyy = now.getFullYear();
  const MM = String(now.getMonth()+1).padStart(2,'0');
  const dd = String(now.getDate()).padStart(2,'0');
  const HH = String(now.getHours()).padStart(2,'0');
  const mm = String(now.getMinutes()).padStart(2,'0');
  const ss = String(now.getSeconds()).padStart(2,'0');
  return {
    datetime: `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`,
    date: `${yyyy}-${MM}-${dd}`,
    hour: now.getHours(),
    dow: now.getDay(),
    month: now.getMonth()+1
  };
}

function confirmPay(){
  if(!cart.length){sfxError();return;}
  const {grand} = calcTotals();
  showModal('決済確認','合計 <b style="font-size:22px;color:var(--mint);">¥'+grand.toLocaleString()+'</b> でよろしいですか?','',[
    {label:'戻る',cls:'mbtn-cancel',action:closeModal},
    {label:'✅ 決済する',cls:'mbtn-ok',action:executePay},
  ]);
}

function executePay(){
  closeModal();
  sfxPay();
  
  const localTime = getLocalDatetime();
  const {subtotalFull, discAmt, taxTotal, grand} = calcTotals();
  const txid = 'TX'+String(txCounter).padStart(4,'0');

  // 割引額の按分: 各明細ごとに個別に丸めると合計が実際の請求額(grand)と
  // 数円ズレることがあるため、最後の明細で端数を吸収して必ず合計が一致するようにする。
  const validCartEntries = cart.filter(item=>products.find(pr=>pr.id===item.pid));
  let allocatedDisc = 0;
  validCartEntries.forEach((item,pos)=>{
    const p=products.find(pr=>pr.id===item.pid);
    const unitFull = Math.round(p.price*(1+p.tax/100));
    const lineFull = unitFull * item.qty;
    let lineDisc;
    if(pos === validCartEntries.length - 1){
      lineDisc = discAmt - allocatedDisc; // 最終行で端数を吸収
    } else {
      lineDisc = Math.round(lineFull * discountPct / 100);
      allocatedDisc += lineDisc;
    }
    sales.push({
      txid, 
      datetime: localTime.datetime, 
      date: localTime.date, 
      hour: localTime.hour, 
      dow: localTime.dow, 
      month: localTime.month,
      product_id: p.id,
      product: p.name,
      category: p.cat,
      price: unitFull,
      quantity: item.qty,
      amount: lineFull - lineDisc,
      tax: p.tax,
      discount_pct: discountPct,
      customer_type: selectedCustomer||'',
      age_band: selectedAge||'',
      note: document.getElementById('tx-note').value||'',
    });
  });

  localStorage.setItem('mise_sales', JSON.stringify(sales));
  txCounter++;
  localStorage.setItem('mise_txcounter', String(txCounter));

  if(csvDirHandle){
    saveDailyCSVToFolder(localTime.date).catch(()=>{});
    autoSaveSettingToFolder().catch(()=>{});
  }

  if(document.getElementById('panel-history').style.display!=='none'){
    renderHistoryList();
  }

  showModal('✅ 決済完了',`
    <div class="paid-screen">
      <div class="paid-circle">✓</div>
      <h2>ありがとうございます!</h2>
      <div class="paid-amt">¥${grand.toLocaleString()}</div>
      <div class="paid-items">${cart.map(item=>{
        const p=products.find(pr=>pr.id===item.pid);
        if(!p) return '';
        const amt=Math.round(p.price*(1+p.tax/100))*(1-discountPct/100)*item.qty;
        return `<div><span>${p.icon} ${escHtml(p.name)} ×${item.qty}</span><span>¥${Math.round(amt).toLocaleString()}</span></div>`;
      }).join('')}</div>
      <div style="font-size:12px;color:var(--text2);">${txid} · ${localTime.datetime}</div>
    </div>
  `,'',[
    {label:'次のお客様へ',cls:'mbtn-ok',action:()=>{
      closeModal();
      resetCart();
    }},
  ]);
}

function resetCart(){
  cart=[];
  discountPct=0;
  selectedCustomer=null;
  selectedAge=null;
  document.getElementById('tx-note').value='';
  document.querySelectorAll('.disc-btn').forEach(b=>b.classList.remove('active'));
  const custom=document.querySelector('.disc-btn[data-disc="-1"]');
  if(custom) custom.textContent='任意';
  const d0=document.querySelector('.disc-btn[data-disc="0"]');
  if(d0){d0.classList.add('active');}
  renderCustomerBar();
  renderAgeBar();
  renderCart();
  updateTxLabel();
  showToast('🌟 リセットしました');
}

function confirmCancel(){
  if(!cart.length){sfxCancel();resetCart();return;}
  sfxCancel();
  showModal('❌ 取引をキャンセル','現在のレシートをすべてクリアします。よろしいですか?','',[
    {label:'戻る',cls:'mbtn-cancel',action:closeModal},
    {label:'キャンセル実行',cls:'mbtn-ok',action:()=>{closeModal();resetCart();}},
  ]);
}

// ═══════════════════════════════════════════════════════
// ─── 商品管理 ────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function selectColor(el){
  document.querySelectorAll('.color-swatch').forEach(s=>s.style.border='3px solid transparent');
  el.style.border='3px solid var(--peach)';
  selectedColor=el.dataset.color;
}

function highlightSwatch(color){
  document.querySelectorAll('.color-swatch').forEach(s=>{
    s.style.border = s.dataset.color===color ? '3px solid var(--peach)' : '3px solid transparent';
  });
}

function saveProduct(){
  const name=document.getElementById('pm-name').value.trim();
  const cat=document.getElementById('pm-cat').value.trim();
  const priceVal=document.getElementById('pm-price').value;
  const price=priceVal===''?NaN:parseInt(priceVal);
  const taxVal=document.getElementById('pm-tax').value;
  const tax=taxVal===''?NaN:parseFloat(taxVal);
  const icon=document.getElementById('pm-icon').value.trim()||'📦';
  const id=document.getElementById('pm-id').value.trim();
  const barcode=document.getElementById('pm-barcode').value.trim();
  const badge=document.getElementById('pm-badge').value.trim();
  const soldOut=document.getElementById('pm-soldout').checked;

  if(!name||!cat||isNaN(price)||isNaN(tax)){sfxError();showToast('⚠️ 商品名・カテゴリ・価格・消費税は必須です');return;}
  if(price<0){sfxError();showToast('⚠️ 価格はマイナスにできません');return;}
  if(tax<0||tax>100){sfxError();showToast('⚠️ 消費税は0〜100の範囲で入力してください');return;}

  if(editingProductId){
    const idx=products.findIndex(p=>p.id===editingProductId);
    if(idx>-1){
      products[idx]={...products[idx],name,cat,price,tax,icon,color:selectedColor,barcode,badge,soldOut};
    }
    editingProductId=null;
    document.getElementById('pm-id').value='';
    document.getElementById('pm-id').readOnly=false;
    document.getElementById('pm-id-note').style.display='none';
    document.getElementById('pm-heading').textContent='📦 商品を登録・編集';
    document.getElementById('pm-save-btn').textContent='✅ 商品を保存';
    document.getElementById('pm-cancel-edit-btn').style.display='none';
  } else {
    const newId=id||'P'+String(Date.now()).slice(-6);
    if(products.find(p=>p.id===newId)){sfxError();showToast('⚠️ IDが重複しています');return;}
    products.push({id:newId,name,cat,price,tax,icon,color:selectedColor,barcode,badge,soldOut});
  }

  localStorage.setItem('mise_products',JSON.stringify(products));
  sfxSave();
  showToast('✅ 商品を保存しました');
  document.getElementById('pm-feedback').textContent='✅ 保存しました!';
  setTimeout(()=>document.getElementById('pm-feedback').textContent='',2000);

  ['pm-name','pm-cat','pm-price','pm-icon','pm-id','pm-barcode','pm-badge'].forEach(id=>document.getElementById(id).value='');
  document.getElementById('pm-tax').value='10';
  document.getElementById('pm-soldout').checked=false;
  selectedColor=DEFAULT_COLOR;
  highlightSwatch(selectedColor);

  renderCatBar();
  renderProducts();
  renderPMList();
  
  if(csvDirHandle){
    autoSaveSettingToFolder().catch(()=>{});
  }
}

function renderPMList(){
  const list=document.getElementById('pm-list');
  list.innerHTML=products.map(p=>`
    <div style="display:flex;align-items:center;gap:10px;background:${p.color||'#fff8f2'};border-radius:14px;padding:10px 14px;border:2px solid var(--border);${p.soldOut?'opacity:.55;':''}">
      <span style="font-size:24px;">${p.icon||'📦'}</span>
      <div style="flex:1;">
        <div style="font-size:14px;font-weight:800;">${escHtml(p.name)}${p.soldOut?' <span style="color:var(--coral);font-size:11px;">(売り切れ中)</span>':''}${p.badge?` <span style="color:var(--text2);font-size:11px;">🏷️${escHtml(p.badge)}</span>`:''}</div>
        <div style="font-size:11px;color:var(--text2);">${escHtml(p.cat)} · ¥${p.price.toLocaleString()} (税${p.tax}%) · ${escHtml(p.id)}</div>
      </div>
      <button style="border:none;background:${p.soldOut?'var(--mint-l)':'var(--peach-l)'};border-radius:10px;padding:6px 10px;font-size:12px;font-weight:700;cursor:pointer;color:${p.soldOut?'var(--mint)':'var(--coral)'};" onclick="toggleSoldOut('${p.id}')">${p.soldOut?'販売再開':'売切にする'}</button>
      <button style="border:none;background:var(--sky-l);border-radius:10px;padding:6px 10px;font-size:12px;font-weight:700;cursor:pointer;color:var(--sky);" onclick="editProduct('${p.id}')">編集</button>
      <button style="border:none;background:var(--peach-l);border-radius:10px;padding:6px 10px;font-size:12px;font-weight:700;cursor:pointer;color:var(--coral);" onclick="deleteProduct('${p.id}')">削除</button>
    </div>`).join('');
}

function toggleSoldOut(id){
  const p=products.find(pr=>pr.id===id);
  if(!p) return;
  p.soldOut=!p.soldOut;
  localStorage.setItem('mise_products',JSON.stringify(products));
  renderPMList();
  renderProducts();
  beep(p.soldOut?330:700,60,'sine',0.2);
  showToast(p.soldOut?`🚫 ${p.name} を売り切れにしました`:`✅ ${p.name} の販売を再開しました`);
  if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
}

function editProduct(id){
  const p=products.find(pr=>pr.id===id);
  if(!p) return;
  editingProductId=id;
  document.getElementById('pm-name').value=p.name;
  document.getElementById('pm-cat').value=p.cat;
  document.getElementById('pm-price').value=p.price;
  document.getElementById('pm-tax').value=p.tax;
  document.getElementById('pm-icon').value=p.icon||'';
  document.getElementById('pm-id').value=p.id;
  document.getElementById('pm-id').readOnly=true;
  document.getElementById('pm-id-note').style.display='inline';
  document.getElementById('pm-barcode').value=p.barcode||'';
  document.getElementById('pm-badge').value=p.badge||'';
  document.getElementById('pm-soldout').checked=!!p.soldOut;
  document.getElementById('pm-heading').textContent='✏️ 商品を編集中: '+p.name;
  document.getElementById('pm-save-btn').textContent='✅ 変更を保存';
  document.getElementById('pm-cancel-edit-btn').style.display='block';
  selectedColor=p.color||DEFAULT_COLOR;
  highlightSwatch(selectedColor);
  setMode('products',null);
  document.getElementById('pm-name').focus();
  sfxSave();
}

function cancelEditProduct(){
  editingProductId=null;
  ['pm-name','pm-cat','pm-price','pm-icon','pm-id','pm-barcode','pm-badge'].forEach(id=>document.getElementById(id).value='');
  document.getElementById('pm-tax').value='10';
  document.getElementById('pm-soldout').checked=false;
  document.getElementById('pm-id').readOnly=false;
  document.getElementById('pm-id-note').style.display='none';
  document.getElementById('pm-heading').textContent='📦 商品を登録・編集';
  document.getElementById('pm-save-btn').textContent='✅ 商品を保存';
  document.getElementById('pm-cancel-edit-btn').style.display='none';
  selectedColor=DEFAULT_COLOR;
  highlightSwatch(selectedColor);
  showToast('編集をキャンセルしました');
}

function deleteProduct(id){
  const p=products.find(pr=>pr.id===id);
  if(!p) return;
  showModal(`「${escHtml(p.name)}」を削除`,`この商品をリストから削除します。過去の売上データは保持されます。`,'',[
    {label:'キャンセル',cls:'mbtn-cancel',action:closeModal},
    {label:'削除する',cls:'mbtn-ok',action:()=>{
      products=products.filter(pr=>pr.id!==id);
      cart=cart.filter(i=>i.pid!==id);
      localStorage.setItem('mise_products',JSON.stringify(products));
      if(editingProductId===id){ cancelEditProduct(); }
      renderCatBar();renderProducts();renderCart();renderPMList();
      closeModal();sfxCancel();showToast('削除しました');
      if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
    }},
  ]);
}

// ═══════════════════════════════════════════════════════
// ─── OCR ─────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
const ocrDrop=document.getElementById('ocr-drop');
ocrDrop.addEventListener('dragover',e=>{e.preventDefault();ocrDrop.classList.add('drag');});
ocrDrop.addEventListener('dragleave',()=>ocrDrop.classList.remove('drag'));
ocrDrop.addEventListener('drop',e=>{
  e.preventDefault();ocrDrop.classList.remove('drag');
  handleOCRFiles(e.dataTransfer.files);
});

// 「ローカルLLM」か「カスタム」かでURL欄の挙動を切り替える
function onOcrEndpointChange(){
  const sel=document.getElementById('ocr-endpoint').value;
  const urlInput=document.getElementById('ocr-url');
  if(sel==='local'){
    if(!urlInput.value) urlInput.value='http://localhost:11434/api/generate';
    urlInput.placeholder='http://localhost:11434/api/generate';
  } else {
    urlInput.value='';
    urlInput.placeholder='カスタムエンドポイントのURLを入力';
    urlInput.focus();
  }
}

function handleOCRFiles(files){
  Array.from(files).forEach(f=>{
    const reader=new FileReader();
    reader.onload=e=>{
      ocrQueue.push({name:f.name,data:e.target.result,type:f.type,status:'pending'});
      renderOCRQueue();
    };
    reader.readAsDataURL(f);
  });
}

function renderOCRQueue(){
  const q=document.getElementById('ocr-queue');
  if(!ocrQueue.length){q.innerHTML='<div style="text-align:center;color:var(--text2);font-size:13px;padding:16px;">レシートをドロップするとここに表示されます</div>';return;}
  q.innerHTML=ocrQueue.map((item,i)=>`
    <div class="ocr-item">
      ${item.type.startsWith('image')?`<img class="ocr-thumb" src="${item.data}">`:'<div class="ocr-thumb" style="display:flex;align-items:center;justify-content:center;font-size:22px;">📄</div>'}
      <div class="ocr-info">
        <div class="ocr-name">${escHtml(item.name)}</div>
        <div class="ocr-status ${item.status}">${
          item.status==='pending'?'待機中...' :
          item.status==='processing'?'🔍 処理中...' :
          item.status==='done'?'✅ 完了' :
          '❌ エラー'
        }</div>
      </div>
      <button onclick="ocrQueue.splice(${i},1);renderOCRQueue();" style="border:none;background:var(--peach-l);border-radius:8px;padding:5px 9px;color:var(--coral);font-size:12px;cursor:pointer;">✕</button>
    </div>`).join('');
}

async function processOCRQueue(){
  const endpoint=document.getElementById('ocr-url').value||'http://localhost:11434/api/generate';
  let processed=0;
  for(let i=0;i<ocrQueue.length;i++){
    if(ocrQueue[i].status!=='pending') continue;
    ocrQueue[i].status='processing';
    renderOCRQueue();
    try{
      const base64=ocrQueue[i].data.split(',')[1];
      const res=await fetch(endpoint,{
        method:'POST',
        headers:{'Content-Type':'application/json'},
        body:JSON.stringify({
          model:'llava',
          prompt:'このレシートから商品名・数量・金額をJSON形式で抽出してください。例: [{"product":"コーヒー","qty":1,"amount":350}]',
          images:[base64],
          stream:false,
        }),
      });
      if(!res.ok) throw new Error('HTTP '+res.status);
      const data=await res.json();
      const text=data.response||'';
      const match=text.match(/\[[\s\S]*?\]/);
      if(match){
        const items=JSON.parse(match[0]);
        const txid = 'OCR'+String(txCounter).padStart(4,'0');
        const localTime = getLocalDatetime();
        
        items.forEach(item=>{
          sales.push({
            txid: txid,
            datetime: localTime.datetime,
            date: localTime.date,
            hour: localTime.hour, dow: localTime.dow, month: localTime.month,
            product_id:'',product:item.product||'不明',category:'OCR',
            price:item.amount||0,quantity:item.qty||1,amount:item.amount||0,
            tax:8,discount_pct:0,customer_type:'',age_band:'',note:'OCR: '+ocrQueue[i].name,
          });
        });
        txCounter++;localStorage.setItem('mise_txcounter',String(txCounter));
        localStorage.setItem('mise_sales',JSON.stringify(sales));
        if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
      }
      ocrQueue[i].status='done';
      processed++;
    }catch(e){
      ocrQueue[i].status='err';
      ocrQueue[i].error=e.message;
    }
    renderOCRQueue();
  }
  if(processed>0){sfxPay();showToast(`✅ ${processed}件のレシートを処理しました`);}
  else{sfxError();showToast('⚠️ 処理できませんでした(ローカルLLMが起動しているか確認してください)');}
}

// ═══════════════════════════════════════════════════════
// ─── CSV出力(全データ) ────────────────────────────────
// ═══════════════════════════════════════════════════════
function exportCSV(){
  if(!sales.length){sfxError();showToast('⚠️ 売上データがありません');return;}
  const headers=['txid','datetime','date','hour','dow','month','product_id','product','category','price','quantity','amount','tax','discount_pct','customer_type','age_band','note'];
  const csv=[headers.join(','),...sales.map(r=>headers.map(h=>{
    const v=r[h]!==undefined?r[h]:'';
    const sv=String(v);
    return sv.includes(',')||sv.includes('"')||sv.includes('\n')?`"${sv.replace(/"/g,'""')}"`:sv;
  }).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_'+new Date().toISOString().slice(0,10)+'.csv';
  a.click();
  setTimeout(()=>URL.revokeObjectURL(a.href),5000);
  sfxSave();
  showToast('📤 '+sales.length+'件のデータを出力しました');
}

// ═══════════════════════════════════════════════════════
// ─── 設定 ────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function openSettings(){
  const folderName = csvDirHandle ? csvDirHandle.name : localStorage.getItem('mise_csv_folder_name') || '未選択';
  const fsSupported = ('showDirectoryPicker' in window);
  const fsNote = fsSupported
    ? `<div style="font-size:11px;color:var(--text2);margin-top:4px;">✅ File System Access API対応ブラウザです。フォルダを選択するとCSVと全データが自動保存されます。</div>`
    : `<div style="font-size:11px;color:#e05050;margin-top:4px;">⚠️ このブラウザはFile System Access APIに対応していません(Chrome/Edge推奨)。CSVはダウンロード保存のみです。</div>`;
  showModal('⚙️ 設定',`
    <div style="text-align:left;max-height:65vh;overflow-y:auto;padding-right:4px;">
      <div style="margin-bottom:14px;">
        <label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">🏪 店舗名</label>
        <input id="set-storename" class="f-input" value="${escAttr(localStorage.getItem('mise_storename')||'')}" placeholder="例: カフェ花まる">
      </div>
      <div style="margin-bottom:14px;">
        <label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">📁 データ保存フォルダ(日別CSV・バックアップ保存先)</label>
        <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
          <div id="set-folder-display" style="flex:1;background:var(--bg2);border:2px solid var(--border);border-radius:10px;padding:7px 12px;font-size:13px;font-weight:700;color:${csvDirHandle?'var(--mint)':'var(--text2)'};">
            ${csvDirHandle ? '📂 ' + escHtml(csvDirHandle.name) : '📁 ' + escHtml(folderName)}
          </div>
          ${fsSupported ? `<button class="disc-btn" style="border-color:var(--sky);color:var(--sky);white-space:nowrap;" onclick="selectCsvFolder()">📂 フォルダ選択</button>` : ''}
        </div>
        ${fsNote}
        <div style="font-size:11px;color:var(--text2);margin-top:4px;">
          デフォルト: HTMLファイルと同じフォルダ。決済後や終了時に自動でこのフォルダへ書き込み・バックアップします。
        </div>
      </div>
      <div style="margin-bottom:14px;">
        <label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">📋 売上データ(件数: ${sales.length}件)</label>
        <div style="display:flex;gap:8px;flex-wrap:wrap;">
          <button class="disc-btn" onclick="exportCSV();closeModal();">📤 全CSV出力</button>
          <button class="disc-btn" style="border-color:var(--coral);color:var(--coral);" onclick="if(confirm('全データを削除します。\\nこの操作は取り消せません。')){sales=[];localStorage.setItem('mise_sales','[]');closeModal();showToast('🗑 データを削除しました');}">🗑 クリア</button>
        </div>
      </div>
      <div style="margin-bottom:14px;">
        <label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">🔢 次の取引番号</label>
        <input id="set-txcounter" class="f-input" type="number" min="1" value="${txCounter}">
      </div>
      <div style="margin-bottom:4px;">
        <label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">⚙️ setting.txt 管理(売上含む全バックアップ)</label>
        <div style="display:flex;gap:8px;flex-wrap:wrap;">
          <button class="disc-btn" style="border-color:var(--sky);color:var(--sky);" onclick="saveSettingTxtManual()">💾 全データを保存</button>
          <button class="disc-btn" style="border-color:var(--sky);color:var(--sky);" onclick="document.getElementById('setting-txt-input').click()">📂 データを復元</button>
          <input type="file" id="setting-txt-input" accept=".txt,.json" style="display:none" onchange="loadSettingTxt(this)">
        </div>
        <div style="font-size:11px;color:var(--text2);margin-top:4px;">保存: 売上を含む全データをsetting.txtとして保存 復元: 以前保存したsetting.txtを適用</div>
      </div>
    </div>
  `,'',[
    {label:'閉じる',cls:'mbtn-cancel',action:closeModal},
    {label:'保存',cls:'mbtn-ok',action:()=>{
      const name=document.getElementById('set-storename')?.value||'';
      const tcRaw=parseInt(document.getElementById('set-txcounter')?.value);
      const tc = (!isNaN(tcRaw) && tcRaw>=1) ? tcRaw : txCounter;
      localStorage.setItem('mise_storename',name);
      txCounter=tc;
      localStorage.setItem('mise_txcounter',String(txCounter));
      updateTxLabel();
      closeModal();sfxSave();showToast('設定を保存しました');
      if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
    }},
  ]);
}

// ═══════════════════════════════════════════════════════
// ─── モード切替 ─────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function setMode(mode, btn){
  document.querySelectorAll('.mode-tab').forEach(b=>b.classList.remove('active'));
  if(btn) btn.classList.add('active');
  else {
    const tabs={'pos':0,'history':1,'products':2,'ocr':3};
    document.querySelectorAll('.mode-tab')[tabs[mode]!==undefined?tabs[mode]:0]?.classList.add('active');
  }
  document.getElementById('panel-pos').style.display=mode==='pos'?'flex':'none';
  document.getElementById('panel-history').style.display=mode==='history'?'flex':'none';
  document.getElementById('panel-products').style.display=mode==='products'?'flex':'none';
  document.getElementById('panel-ocr').style.display=mode==='ocr'?'flex':'none';
  if(mode==='products') renderPMList();
  if(mode==='ocr') renderOCRQueue();
  if(mode==='history') renderHistoryPanel();
}

// ═══════════════════════════════════════════════════════
// ─── 決済リスト ──────────────────────────────────────────
// ═══════════════════════════════════════════════════════

function todayStr(){
  const n=new Date();
  return n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0')+'-'+String(n.getDate()).padStart(2,'0');
}

function renderHistoryPanel(){
  if(!historyViewDate){
    historyViewDate=todayStr();
  }
  const el=document.getElementById('history-date');
  if(el) el.value=historyViewDate;
  selectedTxIds.clear();
  updateDeleteBtn();
  renderHistoryList();
}

function onHistoryDateChange(){
  const el=document.getElementById('history-date');
  historyViewDate=el.value||todayStr();
  selectedTxIds.clear();
  expandedTxIds.clear();
  updateDeleteBtn();
  renderHistoryList();
}

function historyDateOffset(delta){
  const d=new Date(historyViewDate+'T00:00:00');
  d.setDate(d.getDate()+delta);
  historyViewDate=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
  document.getElementById('history-date').value=historyViewDate;
  selectedTxIds.clear();
  expandedTxIds.clear();
  updateDeleteBtn();
  renderHistoryList();
}

function setHistoryDateToday(){
  historyViewDate=todayStr();
  document.getElementById('history-date').value=historyViewDate;
  selectedTxIds.clear();
  expandedTxIds.clear();
  updateDeleteBtn();
  renderHistoryList();
}

function groupSalesByTx(dateStr){
  const dayRows=sales.filter(r=>r.date===dateStr);
  const map=new Map();
  dayRows.forEach(r=>{
    if(!map.has(r.txid)){
      map.set(r.txid,{
        txid:r.txid,
        datetime:r.datetime,
        date:r.date,
        customer_type:r.customer_type||'',
        age_band:r.age_band||'',
        discount_pct:r.discount_pct||0,
        note:r.note||'',
        items:[],
        total:0,
      });
    }
    const tx=map.get(r.txid);
    tx.items.push({
      product_id:r.product_id,
      product:r.product,
      category:r.category,
      price:r.price,
      quantity:r.quantity,
      amount:r.amount,
      tax:r.tax,
    });
    tx.total+=r.amount;
    if(!tx.note&&r.note) tx.note=r.note;
  });
  return [...map.values()].sort((a,b)=>a.datetime.localeCompare(b.datetime));
}

function custLabel(key){
  const map={general:'一般',member:'会員',student:'学生',senior:'シニア',staff:'スタッフ'};
  return map[key]||key||'';
}

function renderHistoryList(){
  const listEl=document.getElementById('history-list');
  const summaryEl=document.getElementById('history-summary');
  const txGroups=groupSalesByTx(historyViewDate);

  if(!txGroups.length){
    listEl.innerHTML='<div class="history-empty"><span>📋</span>この日の決済データがありません</div>';
    summaryEl.innerHTML='<span>0件 ¥0</span>';
    return;
  }

  const totalAmt=txGroups.reduce((s,tx)=>s+tx.total,0);
  const totalQty=txGroups.reduce((s,tx)=>s+tx.items.reduce((q,i)=>q+i.quantity,0),0);
  const selectedCount=txGroups.filter(tx=>selectedTxIds.has(tx.txid)).length;
  const selectedAmt=txGroups.filter(tx=>selectedTxIds.has(tx.txid)).reduce((s,tx)=>s+tx.total,0);

  summaryEl.innerHTML=
    `<span>${txGroups.length}件</span>` +
    `<span>合計: <span style="color:var(--peach);font-family:'Nunito',sans-serif;">¥${totalAmt.toLocaleString()}</span></span>` +
    `<span>点数: ${totalQty}点</span>` +
    (selectedCount>0
      ? `<span style="color:var(--coral);">選択中: ${selectedCount}件 ¥${selectedAmt.toLocaleString()}</span>`
      : '');

  listEl.innerHTML=txGroups.map(tx=>{
    const isSelected=selectedTxIds.has(tx.txid);
    const isExpanded=expandedTxIds.has(tx.txid);
    const timeStr=tx.datetime?tx.datetime.slice(11,16):'--:--';
    const custStr=tx.customer_type?`<span class="htx-cust">${custLabel(tx.customer_type)}${tx.age_band?' '+tx.age_band:''}</span>`:'';
    const discStr=tx.discount_pct>0?`<div class="htx-disc">割引: ${tx.discount_pct}%</div>`:'';
    const noteStr=tx.note?`<div class="htx-note">📝 ${escHtml(tx.note)}</div>`:'';
    const itemsHtml=tx.items.map(it=>`
      <div class="htx-item">
        <span class="htx-item-name">${escHtml(it.product)}</span>
        <span class="htx-item-detail">${it.quantity}点 × ¥${it.price.toLocaleString()}</span>
        <span class="htx-item-amt">¥${it.amount.toLocaleString()}</span>
      </div>`).join('');
    return `<div class="history-tx${isSelected?' selected':''}" data-txid="${escAttr(tx.txid)}">
      <div class="history-tx-header" onclick="toggleTxExpand('${escAttr(tx.txid)}',event)">
        <div class="htx-check${isSelected?' checked':''}" onclick="toggleTxSelect('${escAttr(tx.txid)}',event)" title="選択">${isSelected?'✓':''}</div>
        <span class="htx-id">${escHtml(tx.txid)}</span>
        <span class="htx-time">${timeStr}</span>
        ${custStr}
        <span class="htx-total">¥${tx.total.toLocaleString()}</span>
        <span class="htx-expand${isExpanded?' open':''}">▾</span>
      </div>
      <div class="history-tx-body${isExpanded?' open':''}">
        ${itemsHtml}
        ${discStr}
        ${noteStr}
      </div>
    </div>`;
  }).join('');
}

function escHtml(s){
  return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escAttr(s){
  return String(s||'').replace(/'/g,"\\'").replace(/"/g,'&quot;');
}

function toggleTxExpand(txid,e){
  e.stopPropagation();
  if(expandedTxIds.has(txid)){expandedTxIds.delete(txid);}
  else{expandedTxIds.add(txid);}
  renderHistoryList();
}

function toggleTxSelect(txid,e){
  e.stopPropagation();
  if(selectedTxIds.has(txid)){selectedTxIds.delete(txid);}
  else{selectedTxIds.add(txid);}
  updateDeleteBtn();
  renderHistoryList();
}

function updateDeleteBtn(){
  const btn=document.getElementById('history-del-btn');
  if(btn) btn.style.display=selectedTxIds.size>0?'inline-flex':'none';
}

function deleteSelectedTx(){
  if(!selectedTxIds.size){sfxError();return;}
  const count=selectedTxIds.size;
  showModal('🗑 取引を削除',
    `選択した <b>${count}件</b> の取引を削除します。<br>この操作は取り消せません。`,
    '',
    [
      {label:'キャンセル',cls:'mbtn-cancel',action:closeModal},
      {label:'削除する',cls:'mbtn-ok',action:()=>{
        const txidsToDelete=new Set(selectedTxIds);
        sales=sales.filter(r=>!txidsToDelete.has(r.txid));
        localStorage.setItem('mise_sales',JSON.stringify(sales));
        selectedTxIds.clear();
        expandedTxIds.clear();
        updateDeleteBtn();
        closeModal();
        sfxCancel();
        showToast(`🗑 ${count}件の取引を削除しました`);
        renderHistoryList();
        if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
      }},
    ]
  );
}

async function exportHistoryCSV(){
  const dateStr = historyViewDate || todayStr();
  const dayRows = sales.filter(r=>r.date===dateStr);
  if(!dayRows.length){ sfxError(); showToast('⚠️ この日の売上データがありません'); return; }

  if(csvDirHandle){
    const ok = await saveDailyCSVToFolder(dateStr);
    if(ok){
      sfxSave();
      showToast(`📥 ${dateStr} のCSV(${dayRows.length}件)をフォルダに保存しました`);
      return;
    } else {
      showToast('⚠️ フォルダへの書き込みに失敗。ダウンロードします');
    }
  }
  const headers=['txid','datetime','date','hour','dow','month','product_id','product','category','price','quantity','amount','tax','discount_pct','customer_type','age_band','note'];
  const csv=[headers.join(','),...dayRows.map(r=>headers.map(h=>{
    const v=r[h]!==undefined?r[h]:'';
    const sv=String(v);
    return sv.includes(',')||sv.includes('"')||sv.includes('\n')?`"${sv.replace(/"/g,'""')}"`:sv;
  }).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_sales_'+dateStr+'.csv';
  a.click();
  setTimeout(()=>URL.revokeObjectURL(a.href),5000);
  sfxSave();
  showToast(`📥 ${dateStr} のCSV(${dayRows.length}件)をダウンロードしました`);
}

function importHistoryCSV(input){
  const file=input.files[0];
  if(!file){return;}
  const reader=new FileReader();
  reader.onload=e=>{
    try{
      const text=e.target.result.replace(/^\uFEFF/,'');
      const lines=text.split(/\r?\n/).filter(l=>l.trim());
      if(lines.length<2){sfxError();showToast('⚠️ 有効なデータがありません');return;}
      const headers=splitCSVLine(lines[0]);
      const required=['txid','datetime','date','amount'];
      const missing=required.filter(h=>!headers.includes(h));
      if(missing.length){sfxError();showToast('⚠️ 列が不足: '+missing.join(','));return;}
      const newRows=[];
      for(let i=1;i<lines.length;i++){
        const cells=splitCSVLine(lines[i]);
        if(cells.length<2) continue;
        const row={};
        headers.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.txid||!row.date) continue;
        newRows.push(row);
      }
      if(!newRows.length){sfxError();showToast('⚠️ 有効な行がありませんでした');return;}
      const importTxIds=new Set(newRows.map(r=>r.txid));
      sales=sales.filter(r=>!importTxIds.has(r.txid));
      sales.push(...newRows);
      sales.sort((a,b)=>(a.datetime||a.date||'').localeCompare(b.datetime||b.date||''));
      localStorage.setItem('mise_sales',JSON.stringify(sales));
      sfxSave();
      showToast(`📂 ${newRows.length}件を読み込みました(${importTxIds.size}取引)`);
      if(newRows[0]&&newRows[0].date){
        historyViewDate=newRows[0].date;
        document.getElementById('history-date').value=historyViewDate;
      }
      renderHistoryList();
      if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
    }catch(err){
      sfxError();
      showToast('⚠️ 読込エラー: '+err.message);
    }
    input.value='';
  };
  reader.readAsText(file,'UTF-8');
}

function splitCSVLine(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;
}

// ═══════════════════════════════════════════════════════
// ─── File System Access API ──────────────────────────
// ═══════════════════════════════════════════════════════

async function selectCsvFolder(){
  if(!('showDirectoryPicker' in window)){
    showToast('⚠️ このブラウザはFile System Access APIに対応していません');
    return;
  }
  try{
    const handle = await window.showDirectoryPicker({mode:'readwrite'});
    csvDirHandle = handle;
    await saveHandle(handle);
    localStorage.setItem('mise_csv_folder_name', handle.name);
    showToast('📂 フォルダを設定しました: ' + handle.name);
    sfxSave();
    const disp = document.getElementById('set-folder-display');
    if(disp){
      disp.textContent = '📂 ' + handle.name;
      disp.style.color = 'var(--mint)';
    }
    await autoSaveSettingToFolder();
    hideFolderBanner();
  }catch(e){
    if(e.name !== 'AbortError'){
      sfxError();
      showToast('⚠️ フォルダ選択エラー: ' + e.message);
    }
  }
}

async function writeToDirHandle(dirHandle, fileName, textContent){
  try{
    const fileHandle = await dirHandle.getFileHandle(fileName, {create:true});
    const writable = await fileHandle.createWritable();
    await writable.write(textContent);
    await writable.close();
    return true;
  }catch(e){
    console.error('writeToDirHandle error:', e);
    return false;
  }
}

async function readFromDirHandle(dirHandle, fileName){
  try{
    const fileHandle = await dirHandle.getFileHandle(fileName, {create:false});
    const file = await fileHandle.getFile();
    return await file.text();
  }catch(e){
    if(e.name === 'NotFoundError') return null;
    console.error('readFromDirHandle error:', e);
    return null;
  }
}

async function autoSaveSettingToFolder(){
  if(!csvDirHandle) return;
  try{
    const settings = collectSettings();
    const json = JSON.stringify(settings, null, 2);
    const ok = await writeToDirHandle(csvDirHandle, 'setting.txt', json);
    if(!ok) console.warn('setting.txt の自動保存に失敗しました');
  }catch(e){
    console.warn('autoSaveSettingToFolder:', e);
  }
}

async function saveDailyCSVToFolder(dateStr){
  if(!csvDirHandle) return false;
  const dayRows = sales.filter(r=>r.date===dateStr);
  if(!dayRows.length) return false;
  const headers=['txid','datetime','date','hour','dow','month','product_id','product','category','price','quantity','amount','tax','discount_pct','customer_type','age_band','note'];
  const csv=[headers.join(','),...dayRows.map(r=>headers.map(h=>{
    const v=r[h]!==undefined?r[h]:'';
    const sv=String(v);
    return sv.includes(',')||sv.includes('"')||sv.includes('\n')?`"${sv.replace(/"/g,'""')}"`:sv;
  }).join(','))].join('\n');
  const fileName = 'MISE_sales_' + dateStr + '.csv';
  const ok = await writeToDirHandle(csvDirHandle, fileName, '\uFEFF' + csv);
  return ok;
}

function showFolderBanner(folderName){
  const old = document.getElementById('folder-banner');
  if(old) old.remove();
  const banner = document.createElement('div');
  banner.id = 'folder-banner';
  banner.style.cssText = 'background:linear-gradient(90deg,var(--sky),#3a9de0);color:#fff;padding:8px 16px;font-size:13px;font-weight:700;z-index:700;display:flex;align-items:center;gap:10px;box-shadow:0 3px 12px rgba(0,0,0,.15);flex-shrink:0;';
  banner.innerHTML = `
    <span>📂 前回のフォルダ「<b>${escHtml(folderName)}</b>」を再接続すると全データを自動読み込みします</span>
    <button onclick="reconnectFolder()" style="border:none;background:rgba(255,255,255,.25);color:#fff;border-radius:8px;padding:4px 12px;font-size:12px;font-weight:800;cursor:pointer;font-family:inherit;">📂 フォルダを再接続</button>
    <button onclick="hideFolderBanner()" style="border:none;background:none;color:rgba(255,255,255,.7);font-size:18px;cursor:pointer;margin-left:auto;line-height:1;">✕</button>
  `;
  document.body.insertBefore(banner, document.body.firstChild);
}

function hideFolderBanner(){
  const b = document.getElementById('folder-banner');
  if(b) b.remove();
}

async function reconnectFolder(){
  const storedHandle = await loadHandle().catch(()=>null);
  if(storedHandle){
    try {
      if((await storedHandle.requestPermission({mode:'readwrite'})) === 'granted'){
        csvDirHandle = storedHandle;
        localStorage.setItem('mise_csv_folder_name', csvDirHandle.name);
        hideFolderBanner();
        await loadSettingFromFolder();
        showToast('📂 フォルダから設定と売上データを読み込みました');
        return;
      }
    } catch(e) {
      console.warn('Permission request failed:', e);
    }
  }
  await selectCsvFolder();
  if(csvDirHandle){
    await loadSettingFromFolder();
  }
}

async function loadSettingFromFolder(){
  if(!csvDirHandle) return;
  const text = await readFromDirHandle(csvDirHandle, 'setting.txt');
  if(!text){ return; }
  try{
    const settings = JSON.parse(text);
    applySettings(settings);
    showToast('📂 データをフォルダから自動読み込みしました');
  }catch(e){
    console.warn('loadSettingFromFolder parse error:', e);
  }
}

// ═══════════════════════════════════════════════════════
// ─── setting.txt 保存・読み込み ──────────────────────────
// ═══════════════════════════════════════════════════════
function collectSettings(){
  return {
    version: 2,
    storename: localStorage.getItem('mise_storename')||'',
    csv_folder_name: csvDirHandle ? csvDirHandle.name : (localStorage.getItem('mise_csv_folder_name')||''),
    txcounter: txCounter,
    products: products,
    sales: sales,
    saved_at: new Date().toISOString(),
  };
}

function applySettings(settings){
  if(!settings||typeof settings!=='object') return;
  if(settings.storename !== undefined){
    localStorage.setItem('mise_storename', settings.storename);
    const storenameEl = document.getElementById('set-storename');
    if(storenameEl) storenameEl.value = settings.storename;
  }
  if(settings.csv_folder_name !== undefined && settings.csv_folder_name){
    localStorage.setItem('mise_csv_folder_name', settings.csv_folder_name);
  }
  if(settings.txcounter !== undefined){
    const tc = parseInt(settings.txcounter);
    if(!isNaN(tc) && tc > 0){
      txCounter = tc;
      localStorage.setItem('mise_txcounter', String(txCounter));
      updateTxLabel();
    }
  }
  if(Array.isArray(settings.products) && settings.products.length > 0){
    products = settings.products;
    localStorage.setItem('mise_products', JSON.stringify(products));
    renderCatBar();
    renderProducts();
  }
  if(Array.isArray(settings.sales)){
    sales = settings.sales;
    localStorage.setItem('mise_sales', JSON.stringify(sales));
    if(document.getElementById('panel-history').style.display!=='none'){
      renderHistoryList();
    }
  }
}

function applyStoredSettings(){
  const tc = parseInt(localStorage.getItem('mise_txcounter')||'1');
  if(!isNaN(tc) && tc >= 1){ txCounter = tc; updateTxLabel(); }
}

async function saveSettingTxtManual(){
  const settings = collectSettings();
  const json = JSON.stringify(settings, null, 2);

  if(csvDirHandle){
    const ok = await writeToDirHandle(csvDirHandle, 'setting.txt', json);
    if(ok){
      sfxSave();
      showToast('💾 データをフォルダにバックアップしました');
      return;
    } else {
      showToast('⚠️ フォルダへの書き込みに失敗。ダウンロードします');
    }
  }
  const blob = new Blob([json], {type:'application/json;charset=utf-8'});
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'setting.txt';
  a.click();
  setTimeout(()=>URL.revokeObjectURL(a.href), 5000);
  sfxSave();
  showToast('💾 データをダウンロードしました');
}

function loadSettingTxt(input){
  const file = input.files[0];
  if(!file){ return; }
  const reader = new FileReader();
  reader.onload = e => {
    try{
      const settings = JSON.parse(e.target.result);
      if(!settings||typeof settings!=='object'){ throw new Error('不正なフォーマット'); }
      applySettings(settings);
      sfxSave();
      showToast('📂 データを復元しました');
      closeModal();
      setTimeout(openSettings, 200);
    }catch(err){
      sfxError();
      showToast('⚠️ 読込エラー: ' + err.message);
    }
    input.value = '';
  };
  reader.readAsText(file, 'UTF-8');
}

window.addEventListener('beforeunload', ()=>{
  localStorage.setItem('mise_txcounter', String(txCounter));
  localStorage.setItem('mise_last_saved', new Date().toISOString());
  if(csvDirHandle){
    autoSaveSettingToFolder().catch(()=>{});
  }
});

setInterval(async ()=>{
  if(csvDirHandle){
    await autoSaveSettingToFolder();
    if(sales.filter(r=>r.date===todayStr()).length > 0){
      await saveDailyCSVToFolder(todayStr());
    }
  }
}, 30000);


function showModal(title, body, extra, buttons){
  document.getElementById('modal-title').innerHTML=title;
  document.getElementById('modal-body').innerHTML=body+extra;
  document.getElementById('modal-btns').innerHTML=buttons.map((b,i)=>`
    <button class="${b.cls}" id="mbtn-${i}">${b.label}</button>`).join('');
  buttons.forEach((b,i)=>document.getElementById('mbtn-'+i).onclick=b.action);
  document.getElementById('modal').classList.remove('hidden');
}
function closeModal(){document.getElementById('modal').classList.add('hidden');}
document.getElementById('modal').addEventListener('click',e=>{
  if(e.target===document.getElementById('modal')) closeModal();
});

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'),2200);
}

init();
</script>
</body>
</html>

いいなと思ったら応援しよう!

ピックアップされています

便利なツール

  • 95本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望は、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
小さなお店用のPOSシステム「MisePOS」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1