小さなお店用の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>

コメント