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


画像

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

<!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;min-height:100vh;overflow-x:hidden;user-select:none;}

/* ─── Scanline noise overlay ─── */
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;}

/* ─── Layout ─── */
.pos-wrap{display:grid;grid-template-columns:1fr 380px;min-height:100vh;max-height:100vh;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;}

/* ─── Header ─── */
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-clock{margin-left:auto;font-family:'Nunito',monospace;font-size:22px;font-weight:800;background:rgba(255,255,255,.18);padding:4px 14px;border-radius:12px;}
.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);}

/* ─── Category tabs ─── */
.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 grid ─── */
.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:14px 16px 10px;border-bottom:2px solid var(--border);flex-shrink:0;}
.receipt-header h2{font-size:16px;font-weight:800;color:var(--text);margin-bottom:10px;}

/* 顧客情報 */
.customer-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;}
.cust-label{font-size:11px;color:var(--text2);font-weight:700;width:100%;}
.cust-btn{border:2px solid var(--border);background:var(--bg);border-radius:12px;padding:6px 10px;font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;font-family:inherit;display:flex;align-items:center;gap:4px;}
.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:6px;}
.age-btn{flex:1;border:2px solid var(--border);background:var(--bg);border-radius:10px;padding:5px 4px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;text-align:center;transition:all .15s;}
.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);}

/* レシート items */
.receipt-items{flex:1;overflow-y:auto;padding:10px 16px;}
.receipt-items::-webkit-scrollbar{width:4px;}
.receipt-items::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
.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:8px 16px;background:var(--mint-l);border-top:2px dashed var(--border);border-bottom:2px dashed var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0;}
.disc-label{font-size:12px;font-weight:700;color:var(--text2);}
.disc-btns{display:flex;gap:6px;flex-wrap:wrap;}
.disc-btn{border:2px solid var(--mint);background:var(--surface);color:var(--mint);border-radius:10px;padding:4px 10px;font-size:12px;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:12px 16px;border-top:2px solid var(--border);flex-shrink:0;}
.total-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:13px;color:var(--text2);}
.total-row.grand{font-size:22px;font-weight:900;color:var(--text);font-family:'Nunito',sans-serif;margin-top:8px;}
.total-row.grand span:last-child{color:var(--peach);}
.tax-badge{font-size:11px;background:var(--lemon-l);border:1.5px solid var(--lemon);color:var(--text2);padding:1px 8px;border-radius:8px;font-weight:700;}

/* ボタン */
.action-btns{display:grid;grid-template-columns:1fr 2fr;gap:10px;padding:10px 16px 16px;flex-shrink:0;}
.act-btn{border:none;border-radius:16px;padding:14px 10px;font-size:15px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .18s;display:flex;align-items:center;justify-content:center;gap:6px;}
.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 overlay ─── */
.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;}
.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-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.f-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;}

/* ─── レシートOCRパネル ─── */
.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 16px 8px;flex-shrink:0;}
.note-input{width:100%;border:2px solid var(--border);border-radius:12px;padding:8px 12px;font-size:13px;font-family:inherit;color:var(--text);background:var(--bg);outline:none;resize:none;height:54px;}
.note-input:focus{border-color:var(--sky);}

/* ─── レスポンシブ ─── */
@media (max-width:700px){
  .pos-wrap{grid-template-columns:1fr;}
  .right{position:fixed;bottom:0;left:0;right:0;height:55vh;border-left:none;border-top:2px solid var(--border);z-index:100;}
  .left{height:45vh;}
}
</style>
</head>
<body>

<!-- ─── Header ─── -->
<header>
  <div class="logo">🛒 MISE POS<span>入力ツール</span></div>
  <div class="hdr-clock" id="clock">--:--</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">

  <!-- ─── LEFT: 商品パネル ─── -->
  <div class="left">
    <!-- モードタブ -->
    <div class="top-mode-bar">
      <button class="mode-tab active" onclick="setMode('pos',this)">🛒 レジ</button>
      <button class="mode-tab" onclick="setMode('products',this)">📦 商品登録</button>
      <button class="mode-tab" onclick="setMode('ocr',this)">📸 レシートOCR</button>
    </div>

    <!-- POSモード -->
    <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;">📦 商品を登録・編集</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">
          </div>
          <div class="field-group">
            <label>消費税</label>
            <select class="f-input" id="pm-tax">
              <option value="10">10%(標準)</option>
              <option value="8">8%(軽減)</option>
              <option value="0">0%(非課税)</option>
            </select>
          </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</label>
            <input class="f-input" id="pm-id" placeholder="自動生成">
          </div>
        </div>
        <div class="field-group">
          <label>バーコード / SKU</label>
          <input class="f-input" id="pm-barcode" placeholder="省略可">
        </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()">✅ 商品を保存</button>
        <div id="pm-feedback" style="text-align:center;margin-top:10px;font-size:13px;font-weight:700;color:var(--mint);"></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;"></div>
      </div>
    </div>

    <!-- OCRモード -->
    <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;">
            <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>

  <!-- ─── RIGHT: レシートパネル ─── -->
  <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 class="cust-label">👤 顧客種別</div>
      <div class="customer-bar" id="cust-bar"></div>
      <div class="cust-label" style="margin-top:6px;">🎂 年齢層</div>
      <div class="age-bar" id="age-bar"></div>
    </div>

    <!-- アイテム一覧 -->
    <div class="receipt-items" id="receipt-items">
      <div class="empty-msg"><span>🛍️</span>商品をタップして追加</div>
    </div>

    <!-- 特記事項 -->
    <div class="note-area">
      <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-cancel" onclick="confirmCancel()">❌ キャンセル</button>
      <button class="act-btn act-pay" id="pay-btn" disabled onclick="confirmPay()">✅ 決済する</button>
    </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>
// ═══════════════════════════════════════════════════════
// ─── 音声 ───────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
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';
let selectedColor = '#ffe4d9';
let editingProductId = null;
let ocrQueue = [];
let txCounter = parseInt(localStorage.getItem('mise_txcounter')||'1');

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+'];

// ═══════════════════════════════════════════════════════
// ─── 初期化 ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function init(){
  renderClock();
  setInterval(renderClock,1000);
  renderCatBar();
  renderProducts();
  renderCustomerBar();
  renderAgeBar();
  renderCart();
  updateTxLabel();
}

function renderClock(){
  const n=new Date();
  document.getElementById('clock').textContent=
    String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0');
}

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('');
  // datalist更新
  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" style="background:${p.color||'#fff'}" onclick="addToCart('${p.id}')">
      ${p.badge?`<div class="prod-badge">${p.badge}</div>`:''}
      <span class="prod-icon">${p.icon||'📦'}</span>
      <div class="prod-name">${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;
  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">${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');
  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" 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 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 now = new Date();
  const {subtotalFull, discAmt, taxTotal, grand} = calcTotals();
  const datetime = now.toISOString().replace('T',' ').slice(0,19);
  const date = now.toISOString().slice(0,10);
  const hour = now.getHours();
  const dow = now.getDay();
  const month = now.getMonth()+1;
  const txid = 'TX'+String(txCounter).padStart(4,'0');

  cart.forEach(item=>{
    const p=products.find(pr=>pr.id===item.pid);
    if(!p) return;
    const unitFull = Math.round(p.price*(1+p.tax/100));
    const perItemDisc = Math.round(discountPct/100 * unitFull);
    sales.push({
      txid, datetime, date, hour, dow, month,
      product_id: p.id,
      product: p.name,
      category: p.cat,
      price: unitFull,
      quantity: item.qty,
      amount: (unitFull - perItemDisc) * item.qty,
      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));

  // 完了表示
  const {grand:g2} = calcTotals();
  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} ${p.name} ×${item.qty}</span><span>¥${Math.round(amt).toLocaleString()}</span></div>`;
      }).join('')}</div>
      <div style="font-size:12px;color:var(--text2);">${txid} · ${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 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 saveProduct(){
  const name=document.getElementById('pm-name').value.trim();
  const cat=document.getElementById('pm-cat').value.trim();
  const price=parseInt(document.getElementById('pm-price').value||'0');
  const tax=parseInt(document.getElementById('pm-tax').value||'10');
  const icon=document.getElementById('pm-icon').value.trim()||'📦';
  const id=document.getElementById('pm-id').value.trim();
  const barcode=document.getElementById('pm-barcode').value.trim();

  if(!name||!cat||!price){sfxError();showToast('⚠️ 商品名・カテゴリ・価格は必須です');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};
    }
    editingProductId=null;
    document.getElementById('pm-id').value='';
  } 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});
  }

  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'].forEach(id=>document.getElementById(id).value='');
  document.getElementById('pm-tax').value='10';

  renderCatBar();
  renderProducts();
  renderPMList();
}

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);">
      <span style="font-size:24px;">${p.icon||'📦'}</span>
      <div style="flex:1;">
        <div style="font-size:14px;font-weight:800;">${p.name}</div>
        <div style="font-size:11px;color:var(--text2);">${p.cat} · ¥${p.price.toLocaleString()} (税${p.tax}%) · ${p.id}</div>
      </div>
      <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 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-barcode').value=p.barcode||'';
  selectedColor=p.color||'#ffe4d9';
  document.querySelectorAll('.color-swatch').forEach(s=>{
    s.style.border=s.dataset.color===selectedColor?'3px solid var(--peach)':'3px solid transparent';
  });
  setMode('products',null);
  document.getElementById('pm-name').focus();
  sfxSave();
}

function deleteProduct(id){
  const p=products.find(pr=>pr.id===id);
  if(!p) return;
  showModal(`「${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));
      renderCatBar();renderProducts();renderCart();renderPMList();
      closeModal();sfxCancel();showToast('削除しました');
    }},
  ]);
}

// ═══════════════════════════════════════════════════════
// ─── 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);
});

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">${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||'';
      // JSON抽出試行
      const match=text.match(/\[[\s\S]*?\]/);
      if(match){
        const items=JSON.parse(match[0]);
        items.forEach(item=>{
          const now=new Date();
          sales.push({
            txid:'OCR'+String(txCounter).padStart(4,'0'),
            datetime:now.toISOString().replace('T',' ').slice(0,19),
            date:now.toISOString().slice(0,10),
            hour:now.getHours(),dow:now.getDay(),month:now.getMonth()+1,
            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));
      }
      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]:'';
    return String(v).includes(',')?`"${v}"`: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_sales_'+new Date().toISOString().slice(0,10)+'.csv';
  a.click();
  sfxSave();
  showToast('📤 '+sales.length+'件のデータを出力しました');
}

// ═══════════════════════════════════════════════════════
// ─── 設定 ────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function openSettings(){
  showModal('⚙️ 設定',`
    <div style="text-align:left;">
      <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="${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;">📋 売上データ(件数: ${sales.length}件)</label>
        <div style="display:flex;gap:8px;">
          <button class="disc-btn" onclick="exportCSV();closeModal();">📤 CSV出力</button>
          <button class="disc-btn" style="border-color:var(--coral);color:var(--coral);" onclick="if(confirm('全データを削除します')){sales=[];localStorage.setItem(\'mise_sales\',\'[]\');closeModal();showToast(\'🗑 データを削除しました\');}">🗑 クリア</button>
        </div>
      </div>
      <div>
        <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" value="${txCounter}">
      </div>
    </div>
  `,'',[
    {label:'閉じる',cls:'mbtn-cancel',action:closeModal},
    {label:'保存',cls:'mbtn-ok',action:()=>{
      const name=document.getElementById('set-storename')?.value||'';
      const tc=parseInt(document.getElementById('set-txcounter')?.value||txCounter);
      localStorage.setItem('mise_storename',name);
      txCounter=tc;
      localStorage.setItem('mise_txcounter',String(txCounter));
      updateTxLabel();
      closeModal();sfxSave();showToast('設定を保存しました');
    }},
  ]);
}

// ═══════════════════════════════════════════════════════
// ─── モード切替 ─────────────────────────────────────────
// ═══════════════════════════════════════════════════════
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,'products':1,'ocr':2};
    document.querySelectorAll('.mode-tab')[tabs[mode]||0]?.classList.add('active');
  }
  document.getElementById('panel-pos').style.display=mode==='pos'?'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();
}

// ═══════════════════════════════════════════════════════
// ─── モーダル ────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
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>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小さなお店用の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