小さなお店用のPOSシステム「MisePOS」
【更新履歴】
・2026/2/20 バージョン1.0公開。
・2026/2/21 バージョン1.1公開。
・小規模な小売店向けの売上入力システムです。
・出力された売上データのcsvファイルは、
分析ツールの「Mise」でも使えます。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>MISE POS — レジ入力</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=M+PLUS+Rounded+1c:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #fff8f2;
--bg2: #fff2e6;
--surface:#ffffff;
--peach: #ff8c69;
--peach-l:#ffe4d9;
--coral: #ff6b6b;
--mint: #6bcb8b;
--mint-l: #d9f5e4;
--sky: #5bb8f5;
--sky-l: #d9f0ff;
--lemon: #ffd166;
--lemon-l:#fff5cc;
--purple: #b388f7;
--purple-l:#ede0ff;
--gray: #aaa09a;
--text: #3d2c22;
--text2: #8a7a70;
--border: #f0d8c8;
--shadow: 0 4px 18px rgba(255,100,50,.10);
--r: 20px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body {
background: var(--bg);
color: var(--text);
font-family: 'M PLUS Rounded 1c', sans-serif;
font-size: 15px;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
user-select: none;
display: flex;
flex-direction: column;
}
/* ─── 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 560px;
flex: 1;
overflow: hidden;
}
.left{display:flex;flex-direction:column;overflow:hidden;}
.right{background:var(--surface);border-left:2px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}
/* ─── 右パネル内部の2カラム ─── */
.right-body{display:grid;grid-template-columns:1fr 210px;flex:1;overflow:hidden;min-height:0;}
.right-items-col{display:flex;flex-direction:column;overflow:hidden;border-right:2px solid var(--border);}
.right-info-col{display:flex;flex-direction:column;overflow-y:auto;background:var(--bg2);}
/* ─── Header ─── */
header{background:linear-gradient(90deg,var(--peach) 0%,var(--coral) 100%);color:#fff;padding:12px 20px;display:flex;align-items:center;gap:14px;flex-shrink:0;box-shadow:0 3px 14px rgba(255,100,50,.25);}
.logo{font-family:'Nunito',sans-serif;font-size:24px;font-weight:900;letter-spacing:.04em;}
.logo span{opacity:.7;font-size:14px;font-weight:700;margin-left:6px;}
.hdr-datetime{margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;background:rgba(255,255,255,.18);padding:5px 14px;border-radius:12px;gap:0;}
.hdr-date{font-family:'M PLUS Rounded 1c',sans-serif;font-size:12px;font-weight:700;color:rgba(255,255,255,.85);line-height:1.3;}
.hdr-clock{font-family:'Nunito',monospace;font-size:22px;font-weight:800;color:#fff;line-height:1.2;}
.hdr-icons{display:flex;gap:8px;}
.hdr-btn{background:rgba(255,255,255,.2);border:none;color:#fff;border-radius:10px;padding:6px 12px;font-size:13px;font-family:inherit;font-weight:700;cursor:pointer;transition:all .15s;}
.hdr-btn:hover{background:rgba(255,255,255,.35);}
/* ─── 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:10px 14px 8px;border-bottom:2px solid var(--border);flex-shrink:0;}
.receipt-header h2{font-size:15px;font-weight:800;color:var(--text);margin-bottom:0;}
/* 顧客情報(右カラム用) */
.customer-bar{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:6px;}
.cust-label{font-size:11px;color:var(--text2);font-weight:700;width:100%;margin-top:6px;}
.cust-btn{border:2px solid var(--border);background:var(--surface);border-radius:10px;padding:5px 7px;font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;font-family:inherit;display:flex;align-items:center;gap:3px;}
.cust-btn.active{border-color:currentColor;background:var(--lemon-l);}
.cust-btn:hover:not(.active){border-color:var(--lemon);background:var(--lemon-l);}
.age-bar{display:flex;gap:4px;flex-wrap:wrap;}
.age-btn{border:2px solid var(--border);background:var(--surface);border-radius:8px;padding:4px 5px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;text-align:center;transition:all .15s;flex:1;min-width:calc(33% - 4px);}
.age-btn.active{background:var(--sky-l);border-color:var(--sky);color:var(--sky);}
.age-btn:hover:not(.active){border-color:var(--sky);background:var(--sky-l);}
/* レシート items */
.receipt-items{flex:1;overflow-y:scroll;padding:8px 10px;min-height:0;}
.receipt-items::-webkit-scrollbar{width:6px;}
.receipt-items::-webkit-scrollbar-track{background:var(--bg2);border-radius:4px;}
.receipt-items::-webkit-scrollbar-thumb{background:var(--peach);border-radius:4px;}
.receipt-items::-webkit-scrollbar-thumb:hover{background:var(--coral);}
.empty-msg{text-align:center;color:var(--text2);padding:30px 0;font-size:14px;font-weight:700;}
.empty-msg span{font-size:40px;display:block;margin-bottom:8px;}
.receipt-item{display:flex;align-items:center;gap:8px;padding:9px 10px;background:var(--bg);border-radius:14px;margin-bottom:7px;border:2px solid transparent;transition:all .15s;}
.receipt-item:hover{border-color:var(--border);}
.ri-icon{font-size:22px;flex-shrink:0;}
.ri-name{flex:1;font-size:13px;font-weight:700;line-height:1.2;}
.ri-name small{font-size:11px;color:var(--text2);font-weight:600;}
.ri-qty{display:flex;align-items:center;gap:4px;}
.ri-qbtn{width:28px;height:28px;border-radius:10px;border:2px solid var(--border);background:var(--surface);color:var(--text);font-size:16px;font-weight:900;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;transition:all .15s;font-family:'Nunito',sans-serif;}
.ri-qbtn.minus:hover{background:var(--coral);border-color:var(--coral);color:#fff;}
.ri-qbtn.plus:hover{background:var(--mint);border-color:var(--mint);color:#fff;}
.ri-qnum{font-family:'Nunito',sans-serif;font-size:18px;font-weight:900;min-width:26px;text-align:center;}
.ri-amt{font-family:'Nunito',sans-serif;font-size:15px;font-weight:800;color:var(--peach);white-space:nowrap;}
.ri-del{color:var(--text2);background:none;border:none;cursor:pointer;font-size:18px;transition:color .15s;padding:0 2px;}
.ri-del:hover{color:var(--coral);}
.ri-discount{font-size:11px;color:var(--mint);font-weight:700;}
/* 割引バー */
.discount-bar{padding:6px 10px;background:var(--mint-l);border-top:2px dashed var(--border);border-bottom:2px dashed var(--border);display:flex;flex-direction:column;gap:4px;flex-shrink:0;}
.disc-label{font-size:11px;font-weight:700;color:var(--text2);}
.disc-btns{display:flex;gap:4px;flex-wrap:wrap;}
.disc-btn{border:2px solid var(--mint);background:var(--surface);color:var(--mint);border-radius:8px;padding:3px 8px;font-size:11px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .15s;}
.disc-btn.active{background:var(--mint);color:#fff;}
.disc-btn:hover{background:var(--mint);color:#fff;}
/* 合計 */
.total-area{padding:8px 10px;border-top:2px solid var(--border);flex-shrink:0;}
.total-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;font-size:12px;color:var(--text2);}
.total-row.grand{font-size:18px;font-weight:900;color:var(--text);font-family:'Nunito',sans-serif;margin-top:6px;}
.total-row.grand span:last-child{color:var(--peach);}
.tax-badge{font-size:10px;background:var(--lemon-l);border:1.5px solid var(--lemon);color:var(--text2);padding:1px 6px;border-radius:8px;font-weight:700;}
/* ボタン */
.action-btns{display:grid;grid-template-columns:1fr;gap:6px;padding:6px 10px 10px;flex-shrink:0;}
.act-btn{border:none;border-radius:14px;padding:12px 8px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .18s;display:flex;align-items:center;justify-content:center;gap:5px;}
.act-cancel{background:var(--peach-l);color:var(--coral);}
.act-cancel:hover{background:var(--coral);color:#fff;}
.act-pay{background:linear-gradient(135deg,var(--mint) 0%,#3db870 100%);color:#fff;box-shadow:0 4px 16px rgba(107,203,139,.35);}
.act-pay:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(107,203,139,.45);}
.act-pay:active{transform:scale(.97);}
.act-pay:disabled{background:var(--border);color:var(--text2);box-shadow:none;transform:none;cursor:not-allowed;}
/* ─── Modal 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;}
.product-mgmt::-webkit-scrollbar{width:6px;}
.product-mgmt::-webkit-scrollbar-track{background:var(--bg2);border-radius:4px;}
.product-mgmt::-webkit-scrollbar-thumb{background:var(--peach);border-radius:4px;}
.field-group{margin-bottom:14px;}
.field-group label{display:block;font-size:12px;font-weight:800;color:var(--text2);margin-bottom:5px;}
.f-input{width:100%;border:2px solid var(--border);border-radius:12px;padding:10px 14px;font-size:15px;font-family:inherit;font-weight:600;color:var(--text);background:var(--bg);outline:none;transition:border-color .15s;}
.f-input:focus{border-color:var(--peach);}
.f-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 10px 6px;flex-shrink:0;}
.note-input{width:100%;border:2px solid var(--border);border-radius:10px;padding:6px 10px;font-size:12px;font-family:inherit;color:var(--text);background:var(--surface);outline:none;resize:none;height:46px;}
.note-input:focus{border-color:var(--sky);}
/* ─── 決済リストパネル ─── */
.history-panel{padding:0;overflow:hidden;flex:1;display:flex;flex-direction:column;}
.history-toolbar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--bg2);border-bottom:2px solid var(--border);flex-shrink:0;flex-wrap:wrap;}
.history-toolbar .ht-label{font-size:12px;font-weight:700;color:var(--text2);white-space:nowrap;}
.history-date-input{border:2px solid var(--border);border-radius:10px;padding:5px 10px;font-size:13px;font-family:inherit;font-weight:600;color:var(--text);background:var(--surface);outline:none;cursor:pointer;}
.history-date-input:focus{border-color:var(--peach);}
.history-toolbar .ht-btn{border:2px solid var(--border);background:var(--surface);border-radius:10px;padding:5px 10px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;color:var(--text2);white-space:nowrap;transition:all .15s;}
.history-toolbar .ht-btn:hover{border-color:var(--peach);color:var(--peach);}
.history-toolbar .ht-btn.danger{border-color:var(--coral);color:var(--coral);}
.history-toolbar .ht-btn.danger:hover{background:var(--coral);color:#fff;}
.history-toolbar .ht-btn.primary{border-color:var(--mint);color:var(--mint);}
.history-toolbar .ht-btn.primary:hover{background:var(--mint);color:#fff;}
.history-summary{padding:6px 14px;background:var(--lemon-l);border-bottom:2px solid var(--border);font-size:12px;font-weight:700;color:var(--text2);flex-shrink:0;display:flex;gap:14px;flex-wrap:wrap;}
.history-summary span{color:var(--text);}
.history-list{flex:1;overflow-y:scroll;padding:10px 12px;}
.history-list::-webkit-scrollbar{width:6px;}
.history-list::-webkit-scrollbar-track{background:var(--bg2);border-radius:4px;}
.history-list::-webkit-scrollbar-thumb{background:var(--peach);border-radius:4px;}
.history-list::-webkit-scrollbar-thumb:hover{background:var(--coral);}
.history-tx{background:var(--surface);border:2px solid var(--border);border-radius:14px;margin-bottom:8px;overflow:hidden;transition:all .15s;}
.history-tx.selected{border-color:var(--peach);background:var(--peach-l);}
.history-tx-header{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;}
.history-tx-header:hover{background:rgba(255,140,105,.06);}
.htx-check{width:20px;height:20px;border-radius:6px;border:2px solid var(--border);background:var(--bg);cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:12px;transition:all .15s;}
.htx-check.checked{background:var(--peach);border-color:var(--coral);color:#fff;}
.htx-id{font-family:'Nunito',sans-serif;font-size:13px;font-weight:900;color:var(--peach);min-width:70px;}
.htx-time{font-size:12px;color:var(--text2);font-weight:700;}
.htx-cust{font-size:11px;color:var(--sky);font-weight:700;background:var(--sky-l);border-radius:8px;padding:2px 7px;}
.htx-total{font-family:'Nunito',sans-serif;font-size:16px;font-weight:900;color:var(--text);margin-left:auto;}
.htx-expand{font-size:16px;color:var(--text2);transition:transform .15s;flex-shrink:0;}
.htx-expand.open{transform:rotate(180deg);}
.history-tx-body{display:none;padding:0 12px 10px;border-top:1px solid var(--border);}
.history-tx-body.open{display:block;}
.htx-item{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:12px;border-bottom:1px dotted var(--border);}
.htx-item:last-child{border-bottom:none;}
.htx-item-name{color:var(--text);font-weight:700;}
.htx-item-detail{color:var(--text2);}
.htx-item-amt{font-family:'Nunito',sans-serif;font-weight:800;color:var(--peach);}
.htx-note{font-size:11px;color:var(--text2);margin-top:6px;padding:5px 8px;background:var(--bg2);border-radius:8px;}
.htx-disc{font-size:11px;color:var(--mint);font-weight:700;margin-top:4px;}
.history-empty{text-align:center;color:var(--text2);padding:40px 20px;font-size:14px;font-weight:700;}
.history-empty span{font-size:40px;display:block;margin-bottom:8px;}
/* ─── レスポンシブ ─── */
@media (max-width:900px){
.pos-wrap{display:flex; flex-direction:column;}
.right{height:55vh; border-left:none; border-top:2px solid var(--border); flex-shrink:0;}
.left{flex:1;}
.right-body{grid-template-columns:1fr 180px;}
}
@media (max-width:600px){
.right-body{grid-template-columns:1fr;}
.right-info-col{display:none;}
}
</style>
</head>
<body>
<header>
<div class="logo">🛒 MISE POS<span>入力ツール</span></div>
<div class="hdr-datetime" id="hdr-datetime">
<div class="hdr-date" id="hdr-date">----年--月--日(-)</div>
<div class="hdr-clock" id="clock">--:--</div>
</div>
<div class="hdr-icons">
<button class="hdr-btn" onclick="openSettings()">⚙️ 設定</button>
<button class="hdr-btn" onclick="exportCSV()">📤 CSV出力</button>
</div>
</header>
<div class="pos-wrap">
<div class="left">
<div class="top-mode-bar">
<button class="mode-tab active" onclick="setMode('pos',this)">🛒 レジ</button>
<button class="mode-tab" onclick="setMode('history',this)">📋 決済リスト</button>
<button class="mode-tab" onclick="setMode('products',this)">📦 商品登録</button>
<button class="mode-tab" onclick="setMode('ocr',this)">📸 レシートOCR</button>
</div>
<div id="panel-pos" style="display:flex;flex-direction:column;flex:1;overflow:hidden;">
<div class="cat-bar" id="cat-bar">
<button class="cat-tab active" data-cat="all" onclick="filterCat('all',this)">🏷️ すべて</button>
</div>
<div class="products-area" id="products-grid"></div>
</div>
<div id="panel-products" style="display:none;flex:1;overflow:hidden;">
<div class="product-mgmt" id="product-mgmt">
<h3 style="font-size:16px;font-weight:800;margin-bottom:14px;">📦 商品を登録・編集</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);min-height:20px;"></div>
<h3 style="font-size:15px;font-weight:800;margin:20px 0 12px;">登録済み商品一覧</h3>
<div id="pm-list" style="display:flex;flex-direction:column;gap:8px;padding-bottom:30px;"></div>
</div>
</div>
<div id="panel-ocr" style="display:none;flex:1;overflow:hidden;">
<div class="ocr-panel">
<h3 style="font-size:16px;font-weight:800;margin-bottom:12px;">📸 レシートOCR入力</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px;line-height:1.7;">レシートの写真・スキャンをドロップすると、ローカルLLM/OCRで読み取ります。まとめて複数ファイルを処理できます。</p>
<label class="ocr-drop" id="ocr-drop">
<input type="file" multiple accept="image/*,.pdf" onchange="handleOCRFiles(this.files)" id="ocr-file">
<div style="font-size:40px;margin-bottom:10px;">📄</div>
<div style="font-size:15px;font-weight:800;margin-bottom:4px;">ここにドロップ</div>
<div style="font-size:13px;color:var(--text2);">または クリックして選択</div>
<div style="font-size:11px;color:var(--text2);margin-top:8px;">対応: JPG / PNG / PDF</div>
</label>
<div style="margin-top:14px;display:flex;gap:10px;">
<select class="f-input" id="ocr-endpoint" style="flex:1;font-size:13px;padding:8px;">
<option value="local">ローカルLLM(Ollama / llama.cpp)</option>
<option value="custom">カスタムエンドポイント</option>
</select>
<input class="f-input" id="ocr-url" placeholder="http://localhost:11434/api/generate" style="flex:2;font-size:12px;padding:8px;">
</div>
<div class="ocr-queue" id="ocr-queue">
<div style="text-align:center;color:var(--text2);font-size:13px;padding:16px;">レシートをドロップするとここに表示されます</div>
</div>
<button class="add-prod-btn" style="margin-top:14px;" onclick="processOCRQueue()">🔍 OCR処理を開始</button>
</div>
</div>
<div id="panel-history" style="display:none;flex:1;overflow:hidden;flex-direction:column;">
<div class="history-panel">
<div class="history-toolbar">
<span class="ht-label">📅 日付</span>
<input type="date" class="history-date-input" id="history-date" onchange="onHistoryDateChange()">
<button class="ht-btn" onclick="historyDateOffset(-1)">◀ 前日</button>
<button class="ht-btn" onclick="historyDateOffset(1)">翌日 ▶</button>
<button class="ht-btn" onclick="setHistoryDateToday()">今日</button>
<button class="ht-btn primary" onclick="exportHistoryCSV()" title="表示中の日のCSVを保存">📥 CSV保存</button>
<button class="ht-btn primary" onclick="document.getElementById('history-csv-input').click()" title="CSVを読み込んで復元">📂 CSV読込</button>
<input type="file" id="history-csv-input" accept=".csv" style="display:none" onchange="importHistoryCSV(this)">
<button class="ht-btn danger" id="history-del-btn" onclick="deleteSelectedTx()" style="display:none;">🗑 選択を削除</button>
</div>
<div class="history-summary" id="history-summary">
<span>データなし</span>
</div>
<div class="history-list" id="history-list">
<div class="history-empty"><span>📋</span>決済データがありません</div>
</div>
</div>
</div>
</div>
<div class="right">
<div class="receipt-header">
<h2>🧾 レシート <span style="font-size:12px;font-weight:600;color:var(--text2);" id="txid-label"></span></h2>
</div>
<div class="right-body">
<div class="right-items-col">
<div class="receipt-items" id="receipt-items">
<div class="empty-msg"><span>🛍️</span>商品をタップして追加</div>
</div>
</div>
<div class="right-info-col">
<div style="padding:8px 10px;border-bottom:2px solid var(--border);flex-shrink:0;">
<div class="cust-label">👤 顧客種別</div>
<div class="customer-bar" id="cust-bar"></div>
<div class="cust-label">🎂 年齢層</div>
<div class="age-bar" id="age-bar"></div>
</div>
<div class="note-area" style="padding-top:8px;">
<div style="font-size:11px;color:var(--text2);font-weight:700;margin-bottom:4px;">📝 メモ</div>
<textarea class="note-input" id="tx-note" placeholder="特記事項..."></textarea>
</div>
<div class="discount-bar">
<span class="disc-label">💚 割引</span>
<div class="disc-btns">
<button class="disc-btn" data-disc="0" onclick="setDiscount(0,this)">なし</button>
<button class="disc-btn" data-disc="5" onclick="setDiscount(5,this)">5%</button>
<button class="disc-btn" data-disc="10" onclick="setDiscount(10,this)">10%</button>
<button class="disc-btn" data-disc="15" onclick="setDiscount(15,this)">15%</button>
<button class="disc-btn" data-disc="-1" onclick="openCustomDiscount()">任意</button>
</div>
</div>
<div class="total-area">
<div class="total-row"><span>小計</span><span id="subtotal">¥0</span></div>
<div class="total-row"><span>割引 <span class="tax-badge" id="disc-badge">0%</span></span><span id="disc-amt" style="color:var(--mint);">-¥0</span></div>
<div class="total-row"><span>消費税</span><span id="tax-amt">¥0</span></div>
<div class="total-row grand"><span>合計</span><span id="grand-total">¥0</span></div>
</div>
<div class="action-btns">
<button class="act-btn act-pay" id="pay-btn" disabled onclick="confirmPay()">✅ 決済する</button>
<button class="act-btn act-cancel" onclick="confirmCancel()">❌ キャンセル</button>
</div>
</div></div></div>
</div>
<div class="modal hidden" id="modal">
<div class="modal-box" id="modal-box">
<div class="modal-title" id="modal-title"></div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-btns" id="modal-btns"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ═══════════════════════════════════════════════════════
// ─── IndexedDB Utils (ファイルハンドル永続化用) ────────
// ═══════════════════════════════════════════════════════
const DB_NAME = 'mise_pos_db';
const STORE_NAME = 'handles';
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (e) => {
e.target.result.createObjectStore(STORE_NAME);
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = () => reject(request.error);
});
}
async function saveHandle(handle) {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(handle, 'csvDirHandle');
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function loadHandle() {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get('csvDirHandle');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(tx.error);
});
}
async function verifyPermission(fileHandle, readWrite) {
const options = {};
if (readWrite) {
options.mode = 'readwrite';
}
// ユーザー操作なしで権限があるかチェック
if ((await fileHandle.queryPermission(options)) === 'granted') {
return true;
}
return false;
}
// ═══════════════════════════════════════════════════════
// ─── 音声 ───────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let actx = null;
function getCtx(){if(!actx)actx=new AudioCtx();return actx;}
function beep(freq=880, dur=80, type='sine', vol=0.3, delay=0){
try{
const ctx=getCtx();
const o=ctx.createOscillator();
const g=ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.type=type; o.frequency.value=freq;
g.gain.setValueAtTime(0,ctx.currentTime+delay);
g.gain.linearRampToValueAtTime(vol,ctx.currentTime+delay+0.01);
g.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+delay+dur/1000);
o.start(ctx.currentTime+delay);
o.stop(ctx.currentTime+delay+dur/1000+0.05);
}catch(e){}
}
function sfxAdd(){beep(880,70,'sine');}
function sfxRemove(){beep(440,80,'sine');}
function sfxCancel(){beep(330,120,'sawtooth',0.15);}
function sfxPay(){beep(660,60,'sine');setTimeout(()=>beep(880,80,'sine'),80);setTimeout(()=>beep(1100,150,'sine'),170);}
function sfxError(){beep(220,200,'sawtooth',0.2);}
function sfxSave(){beep(700,60,'sine');setTimeout(()=>beep(900,80,'sine'),90);}
// ═══════════════════════════════════════════════════════
// ─── データ ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
let products = JSON.parse(localStorage.getItem('mise_products') || 'null') || [
{id:'P001',name:'カフェラテ',cat:'ドリンク',price:450,tax:10,icon:'☕',color:'#ffe4d9'},
{id:'P002',name:'抹茶ラテ',cat:'ドリンク',price:480,tax:10,icon:'🍵',color:'#d9f5e4'},
{id:'P003',name:'クロワッサン',cat:'パン',price:220,tax:8,icon:'🥐',color:'#fff5cc'},
{id:'P004',name:'チーズケーキ',cat:'スイーツ',price:380,tax:8,icon:'🍰',color:'#ede0ff'},
{id:'P005',name:'ブレンドコーヒー',cat:'ドリンク',price:350,tax:10,icon:'☕',color:'#d9f0ff'},
{id:'P006',name:'ホットサンド',cat:'フード',price:600,tax:8,icon:'🥪',color:'#ffd9d9'},
{id:'P007',name:'レモネード',cat:'ドリンク',price:400,tax:10,icon:'🍋',color:'#fff5cc'},
{id:'P008',name:'ガトーショコラ',cat:'スイーツ',price:420,tax:8,icon:'🍫',color:'#ede0ff'},
{id:'P009',name:'オレンジジュース',cat:'ドリンク',price:380,tax:10,icon:'🍊',color:'#ffe4d9'},
{id:'P010',name:'マフィン',cat:'パン',price:280,tax:8,icon:'🧁',color:'#ffd9d9'},
];
let sales = JSON.parse(localStorage.getItem('mise_sales') || '[]');
let cart = [];
let discountPct = 0;
let selectedCustomer = null;
let selectedAge = null;
let currentCat = 'all';
let selectedColor = '#ffe4d9';
let editingProductId = null;
let ocrQueue = [];
let txCounter = parseInt(localStorage.getItem('mise_txcounter')||'1');
// ─── File System Access API: フォルダハンドル(セッション中のみ保持) ───
let csvDirHandle = null; // FileSystemDirectoryHandle
// ─── 決済リスト状態 ───
let selectedTxIds = new Set();
let expandedTxIds = new Set();
let historyViewDate = '';
const customerTypes = [
{key:'general', label:'一般', icon:'👤'},
{key:'member', label:'会員', icon:'🌟'},
{key:'student', label:'学生', icon:'🎓'},
{key:'senior', label:'シニア', icon:'👴'},
{key:'staff', label:'スタッフ', icon:'🏷️'},
];
const ageBands = ['〜19','20-29','30-39','40-49','50-59','60+'];
// ═══════════════════════════════════════════════════════
// ─── 初期化 ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
async function init(){
historyViewDate=todayStr();
renderClock();
setInterval(renderClock,1000);
renderCatBar();
renderProducts();
renderCustomerBar();
renderAgeBar();
renderCart();
updateTxLabel();
// 起動時: localStorageに保存されたsetting内容(前回終了時に保存済み)を反映
applyStoredSettings();
// File System Access API対応ブラウザであれば、保存されたハンドルの復旧を試みる
if('showDirectoryPicker' in window){
const storedHandle = await loadHandle().catch(()=>null);
if(storedHandle){
try {
const hasPerm = await verifyPermission(storedHandle, true);
if(hasPerm){
csvDirHandle = storedHandle;
localStorage.setItem('mise_csv_folder_name', csvDirHandle.name);
await loadSettingFromFolder();
const disp = document.getElementById('set-folder-display');
if(disp){
disp.textContent = '📂 ' + csvDirHandle.name;
disp.style.color = 'var(--mint)';
}
} else {
showFolderBanner(storedHandle.name);
}
} catch(e) {
showFolderBanner(storedHandle.name || localStorage.getItem('mise_csv_folder_name'));
}
} else {
const lastName = localStorage.getItem('mise_csv_folder_name');
if(lastName){
showFolderBanner(lastName);
}
}
}
}
function renderClock(){
const n=new Date();
document.getElementById('clock').textContent=
String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0');
const DOW_JP=['日','月','火','水','木','金','土'];
document.getElementById('hdr-date').textContent=
n.getFullYear()+'年'+(n.getMonth()+1)+'月'+n.getDate()+'日('+DOW_JP[n.getDay()]+')';
}
function updateTxLabel(){
document.getElementById('txid-label').textContent='#'+String(txCounter).padStart(4,'0');
}
// ─── カテゴリバー ─────────────────────────────────────────
function renderCatBar(){
const cats=['all',...new Set(products.map(p=>p.cat))];
const bar=document.getElementById('cat-bar');
bar.innerHTML=cats.map(c=>`<button class="cat-tab${c===currentCat?' active':''}" onclick="filterCat('${c}',this)">
${c==='all'?'🏷️ すべて':c}
</button>`).join('');
// 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 getLocalDatetime() {
const now = new Date();
const yyyy = now.getFullYear();
const MM = String(now.getMonth()+1).padStart(2,'0');
const dd = String(now.getDate()).padStart(2,'0');
const HH = String(now.getHours()).padStart(2,'0');
const mm = String(now.getMinutes()).padStart(2,'0');
const ss = String(now.getSeconds()).padStart(2,'0');
return {
datetime: `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`,
date: `${yyyy}-${MM}-${dd}`,
hour: now.getHours(),
dow: now.getDay(),
month: now.getMonth()+1
};
}
function confirmPay(){
if(!cart.length){sfxError();return;}
const {grand} = calcTotals();
showModal('決済確認','合計 <b style="font-size:22px;color:var(--mint);">¥'+grand.toLocaleString()+'</b> でよろしいですか?','',[
{label:'戻る',cls:'mbtn-cancel',action:closeModal},
{label:'✅ 決済する',cls:'mbtn-ok',action:executePay},
]);
}
function executePay(){
closeModal();
sfxPay();
const localTime = getLocalDatetime();
const {subtotalFull, discAmt, taxTotal, grand} = calcTotals();
const txid = 'TX'+String(txCounter).padStart(4,'0');
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: localTime.datetime,
date: localTime.date,
hour: localTime.hour,
dow: localTime.dow,
month: localTime.month,
product_id: p.id,
product: p.name,
category: p.cat,
price: unitFull,
quantity: item.qty,
amount: (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));
// フォルダが設定されていれば日別CSVとsetting.txtを自動保存
if(csvDirHandle){
saveDailyCSVToFolder(localTime.date).catch(()=>{});
autoSaveSettingToFolder().catch(()=>{});
}
// 決済リストが表示中なら更新
if(document.getElementById('panel-history').style.display!=='none'){
renderHistoryList();
}
// 完了表示
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} · ${localTime.datetime}</div>
</div>
`,'',[
{label:'次のお客様へ',cls:'mbtn-ok',action:()=>{
closeModal();
resetCart();
}},
]);
}
function resetCart(){
cart=[];
discountPct=0;
selectedCustomer=null;
selectedAge=null;
document.getElementById('tx-note').value='';
document.querySelectorAll('.disc-btn').forEach(b=>b.classList.remove('active'));
const 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 priceVal=document.getElementById('pm-price').value;
const price=priceVal===''?NaN:parseInt(priceVal); // 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||isNaN(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();
if(csvDirHandle){
autoSaveSettingToFolder().catch(()=>{});
}
}
function renderPMList(){
const list=document.getElementById('pm-list');
list.innerHTML=products.map(p=>`
<div style="display:flex;align-items:center;gap:10px;background:${p.color||'#fff8f2'};border-radius:14px;padding:10px 14px;border:2px solid var(--border);">
<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('削除しました');
if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
}},
]);
}
// ═══════════════════════════════════════════════════════
// ─── OCR ─────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
const ocrDrop=document.getElementById('ocr-drop');
ocrDrop.addEventListener('dragover',e=>{e.preventDefault();ocrDrop.classList.add('drag');});
ocrDrop.addEventListener('dragleave',()=>ocrDrop.classList.remove('drag'));
ocrDrop.addEventListener('drop',e=>{
e.preventDefault();ocrDrop.classList.remove('drag');
handleOCRFiles(e.dataTransfer.files);
});
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]);
const txid = 'OCR'+String(txCounter).padStart(4,'0');
const localTime = getLocalDatetime();
items.forEach(item=>{
sales.push({
txid: txid,
datetime: localTime.datetime,
date: localTime.date,
hour: localTime.hour, dow: localTime.dow, month: localTime.month,
product_id:'',product:item.product||'不明',category:'OCR',
price:item.amount||0,quantity:item.qty||1,amount:item.amount||0,
tax:8,discount_pct:0,customer_type:'',age_band:'',note:'OCR: '+ocrQueue[i].name,
});
});
txCounter++;localStorage.setItem('mise_txcounter',String(txCounter));
localStorage.setItem('mise_sales',JSON.stringify(sales));
if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
}
ocrQueue[i].status='done';
processed++;
}catch(e){
ocrQueue[i].status='err';
ocrQueue[i].error=e.message;
}
renderOCRQueue();
}
if(processed>0){sfxPay();showToast(`✅ ${processed}件のレシートを処理しました`);}
else{sfxError();showToast('⚠️ 処理できませんでした(ローカルLLMが起動しているか確認してください)');}
}
// ═══════════════════════════════════════════════════════
// ─── CSV出力(全データ) ────────────────────────────────
// ═══════════════════════════════════════════════════════
function exportCSV(){
if(!sales.length){sfxError();showToast('⚠️ 売上データがありません');return;}
const headers=['txid','datetime','date','hour','dow','month','product_id','product','category','price','quantity','amount','tax','discount_pct','customer_type','age_band','note'];
const csv=[headers.join(','),...sales.map(r=>headers.map(h=>{
const v=r[h]!==undefined?r[h]:'';
const sv=String(v);
return sv.includes(',')||sv.includes('"')||sv.includes('\n')?`"${sv.replace(/"/g,'""')}"`:sv;
}).join(','))].join('\n');
const blob=new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='MISE_all_sales_'+new Date().toISOString().slice(0,10)+'.csv';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),5000);
sfxSave();
showToast('📤 '+sales.length+'件のデータを出力しました');
}
// ═══════════════════════════════════════════════════════
// ─── 設定 ────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function openSettings(){
const folderName = csvDirHandle ? csvDirHandle.name : localStorage.getItem('mise_csv_folder_name') || '未選択';
const fsSupported = ('showDirectoryPicker' in window);
const fsNote = fsSupported
? `<div style="font-size:11px;color:var(--text2);margin-top:4px;">✅ File System Access API対応ブラウザです。フォルダを選択するとCSVと全データが自動保存されます。</div>`
: `<div style="font-size:11px;color:#e05050;margin-top:4px;">⚠️ このブラウザはFile System Access APIに対応していません(Chrome/Edge推奨)。CSVはダウンロード保存のみです。</div>`;
showModal('⚙️ 設定',`
<div style="text-align:left;max-height:65vh;overflow-y:auto;padding-right:4px;">
<div style="margin-bottom:14px;">
<label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">🏪 店舗名</label>
<input id="set-storename" class="f-input" value="${escAttr(localStorage.getItem('mise_storename')||'')}" placeholder="例: カフェ花まる">
</div>
<div style="margin-bottom:14px;">
<label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">📁 データ保存フォルダ(日別CSV・バックアップ保存先)</label>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<div id="set-folder-display" style="flex:1;background:var(--bg2);border:2px solid var(--border);border-radius:10px;padding:7px 12px;font-size:13px;font-weight:700;color:${csvDirHandle?'var(--mint)':'var(--text2)'};">
${csvDirHandle ? '📂 ' + escHtml(csvDirHandle.name) : '📁 ' + escHtml(folderName)}
</div>
${fsSupported ? `<button class="disc-btn" style="border-color:var(--sky);color:var(--sky);white-space:nowrap;" onclick="selectCsvFolder()">📂 フォルダ選択</button>` : ''}
</div>
${fsNote}
<div style="font-size:11px;color:var(--text2);margin-top:4px;">
デフォルト: HTMLファイルと同じフォルダ。決済後や終了時に自動でこのフォルダへ書き込み・バックアップします。
</div>
</div>
<div style="margin-bottom:14px;">
<label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">📋 売上データ(件数: ${sales.length}件)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="disc-btn" onclick="exportCSV();closeModal();">📤 全CSV出力</button>
<button class="disc-btn" style="border-color:var(--coral);color:var(--coral);" onclick="if(confirm('全データを削除します。\\nこの操作は取り消せません。')){sales=[];localStorage.setItem('mise_sales','[]');closeModal();showToast('🗑 データを削除しました');}">🗑 クリア</button>
</div>
</div>
<div style="margin-bottom:14px;">
<label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">🔢 次の取引番号</label>
<input id="set-txcounter" class="f-input" type="number" value="${txCounter}">
</div>
<div style="margin-bottom:4px;">
<label style="font-size:12px;font-weight:700;color:var(--text2);display:block;margin-bottom:5px;">⚙️ setting.txt 管理(売上含む全バックアップ)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="disc-btn" style="border-color:var(--sky);color:var(--sky);" onclick="saveSettingTxtManual()">💾 全データを保存</button>
<button class="disc-btn" style="border-color:var(--sky);color:var(--sky);" onclick="document.getElementById('setting-txt-input').click()">📂 データを復元</button>
<input type="file" id="setting-txt-input" accept=".txt,.json" style="display:none" onchange="loadSettingTxt(this)">
</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px;">保存: 売上を含む全データをsetting.txtとして保存 復元: 以前保存したsetting.txtを適用</div>
</div>
</div>
`,'',[
{label:'閉じる',cls:'mbtn-cancel',action:closeModal},
{label:'保存',cls:'mbtn-ok',action:()=>{
const name=document.getElementById('set-storename')?.value||'';
const 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('設定を保存しました');
if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
}},
]);
}
// ═══════════════════════════════════════════════════════
// ─── モード切替 ─────────────────────────────────────────
// ═══════════════════════════════════════════════════════
function setMode(mode, btn){
document.querySelectorAll('.mode-tab').forEach(b=>b.classList.remove('active'));
if(btn) btn.classList.add('active');
else {
const tabs={'pos':0,'history':1,'products':2,'ocr':3};
document.querySelectorAll('.mode-tab')[tabs[mode]!==undefined?tabs[mode]:0]?.classList.add('active');
}
document.getElementById('panel-pos').style.display=mode==='pos'?'flex':'none';
document.getElementById('panel-history').style.display=mode==='history'?'flex':'none';
document.getElementById('panel-products').style.display=mode==='products'?'flex':'none';
document.getElementById('panel-ocr').style.display=mode==='ocr'?'flex':'none';
if(mode==='products') renderPMList();
if(mode==='ocr') renderOCRQueue();
if(mode==='history') renderHistoryPanel();
}
// ═══════════════════════════════════════════════════════
// ─── 決済リスト ──────────────────────────────────────────
// ═══════════════════════════════════════════════════════
// 今日の日付文字列
function todayStr(){
const n=new Date();
return n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0')+'-'+String(n.getDate()).padStart(2,'0');
}
// 決済リストパネルを初期化して描画
function renderHistoryPanel(){
if(!historyViewDate){
historyViewDate=todayStr();
}
const el=document.getElementById('history-date');
if(el) el.value=historyViewDate;
selectedTxIds.clear();
updateDeleteBtn();
renderHistoryList();
}
// 日付変更
function onHistoryDateChange(){
const el=document.getElementById('history-date');
historyViewDate=el.value||todayStr();
selectedTxIds.clear();
expandedTxIds.clear();
updateDeleteBtn();
renderHistoryList();
}
// 前日・翌日
function historyDateOffset(delta){
const d=new Date(historyViewDate+'T00:00:00');
d.setDate(d.getDate()+delta);
historyViewDate=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
document.getElementById('history-date').value=historyViewDate;
selectedTxIds.clear();
expandedTxIds.clear();
updateDeleteBtn();
renderHistoryList();
}
// 今日へ
function setHistoryDateToday(){
historyViewDate=todayStr();
document.getElementById('history-date').value=historyViewDate;
selectedTxIds.clear();
expandedTxIds.clear();
updateDeleteBtn();
renderHistoryList();
}
// 日付の売上データを取引ごとにまとめる
function groupSalesByTx(dateStr){
const dayRows=sales.filter(r=>r.date===dateStr);
// txidでグルーピング
const map=new Map();
dayRows.forEach(r=>{
if(!map.has(r.txid)){
map.set(r.txid,{
txid:r.txid,
datetime:r.datetime,
date:r.date,
customer_type:r.customer_type||'',
age_band:r.age_band||'',
discount_pct:r.discount_pct||0,
note:r.note||'',
items:[],
total:0,
});
}
const tx=map.get(r.txid);
tx.items.push({
product_id:r.product_id,
product:r.product,
category:r.category,
price:r.price,
quantity:r.quantity,
amount:r.amount,
tax:r.tax,
});
tx.total+=r.amount;
// 最初の行のnoteを使用
if(!tx.note&&r.note) tx.note=r.note;
});
return [...map.values()].sort((a,b)=>a.datetime.localeCompare(b.datetime));
}
// 顧客種別ラベル変換
function custLabel(key){
const map={general:'一般',member:'会員',student:'学生',senior:'シニア',staff:'スタッフ'};
return map[key]||key||'';
}
// 決済リスト描画
function renderHistoryList(){
const listEl=document.getElementById('history-list');
const summaryEl=document.getElementById('history-summary');
const txGroups=groupSalesByTx(historyViewDate);
if(!txGroups.length){
listEl.innerHTML='<div class="history-empty"><span>📋</span>この日の決済データがありません</div>';
summaryEl.innerHTML='<span>0件 ¥0</span>';
return;
}
// サマリー計算
const totalAmt=txGroups.reduce((s,tx)=>s+tx.total,0);
const totalQty=txGroups.reduce((s,tx)=>s+tx.items.reduce((q,i)=>q+i.quantity,0),0);
const selectedCount=txGroups.filter(tx=>selectedTxIds.has(tx.txid)).length;
const selectedAmt=txGroups.filter(tx=>selectedTxIds.has(tx.txid)).reduce((s,tx)=>s+tx.total,0);
summaryEl.innerHTML=
`<span>${txGroups.length}件</span>` +
`<span>合計: <span style="color:var(--peach);font-family:'Nunito',sans-serif;">¥${totalAmt.toLocaleString()}</span></span>` +
`<span>点数: ${totalQty}点</span>` +
(selectedCount>0
? `<span style="color:var(--coral);">選択中: ${selectedCount}件 ¥${selectedAmt.toLocaleString()}</span>`
: '');
listEl.innerHTML=txGroups.map(tx=>{
const isSelected=selectedTxIds.has(tx.txid);
const isExpanded=expandedTxIds.has(tx.txid);
const timeStr=tx.datetime?tx.datetime.slice(11,16):'--:--';
const custStr=tx.customer_type?`<span class="htx-cust">${custLabel(tx.customer_type)}${tx.age_band?' '+tx.age_band:''}</span>`:'';
const discStr=tx.discount_pct>0?`<div class="htx-disc">割引: ${tx.discount_pct}%</div>`:'';
const noteStr=tx.note?`<div class="htx-note">📝 ${escHtml(tx.note)}</div>`:'';
const itemsHtml=tx.items.map(it=>`
<div class="htx-item">
<span class="htx-item-name">${escHtml(it.product)}</span>
<span class="htx-item-detail">${it.quantity}点 × ¥${it.price.toLocaleString()}</span>
<span class="htx-item-amt">¥${it.amount.toLocaleString()}</span>
</div>`).join('');
return `<div class="history-tx${isSelected?' selected':''}" data-txid="${escAttr(tx.txid)}">
<div class="history-tx-header" onclick="toggleTxExpand('${escAttr(tx.txid)}',event)">
<div class="htx-check${isSelected?' checked':''}" onclick="toggleTxSelect('${escAttr(tx.txid)}',event)" title="選択">${isSelected?'✓':''}</div>
<span class="htx-id">${escHtml(tx.txid)}</span>
<span class="htx-time">${timeStr}</span>
${custStr}
<span class="htx-total">¥${tx.total.toLocaleString()}</span>
<span class="htx-expand${isExpanded?' open':''}">▾</span>
</div>
<div class="history-tx-body${isExpanded?' open':''}">
${itemsHtml}
${discStr}
${noteStr}
</div>
</div>`;
}).join('');
}
// HTML特殊文字エスケープ
function escHtml(s){
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function escAttr(s){
return String(s||'').replace(/'/g,"\\'").replace(/"/g,'"');
}
// 取引の展開・折りたたみ
function toggleTxExpand(txid,e){
e.stopPropagation();
if(expandedTxIds.has(txid)){expandedTxIds.delete(txid);}
else{expandedTxIds.add(txid);}
renderHistoryList();
}
// 取引の選択切り替え
function toggleTxSelect(txid,e){
e.stopPropagation();
if(selectedTxIds.has(txid)){selectedTxIds.delete(txid);}
else{selectedTxIds.add(txid);}
updateDeleteBtn();
renderHistoryList();
}
// 削除ボタン表示制御
function updateDeleteBtn(){
const btn=document.getElementById('history-del-btn');
if(btn) btn.style.display=selectedTxIds.size>0?'inline-flex':'none';
}
// 選択した取引を削除
function deleteSelectedTx(){
if(!selectedTxIds.size){sfxError();return;}
const count=selectedTxIds.size;
showModal('🗑 取引を削除',
`選択した <b>${count}件</b> の取引を削除します。<br>この操作は取り消せません。`,
'',
[
{label:'キャンセル',cls:'mbtn-cancel',action:closeModal},
{label:'削除する',cls:'mbtn-ok',action:()=>{
const txidsToDelete=new Set(selectedTxIds);
sales=sales.filter(r=>!txidsToDelete.has(r.txid));
localStorage.setItem('mise_sales',JSON.stringify(sales));
selectedTxIds.clear();
expandedTxIds.clear();
updateDeleteBtn();
closeModal();
sfxCancel();
showToast(`🗑 ${count}件の取引を削除しました`);
renderHistoryList();
if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
}},
]
);
}
// 表示中の日の決済リストをCSVに保存(日別ファイル名)
async function exportHistoryCSV(){
const dateStr = historyViewDate || todayStr();
const dayRows = sales.filter(r=>r.date===dateStr);
if(!dayRows.length){ sfxError(); showToast('⚠️ この日の売上データがありません'); return; }
if(csvDirHandle){
// フォルダへ書き込み
const ok = await saveDailyCSVToFolder(dateStr);
if(ok){
sfxSave();
showToast(`📥 ${dateStr} のCSV(${dayRows.length}件)をフォルダに保存しました`);
return;
} else {
showToast('⚠️ フォルダへの書き込みに失敗。ダウンロードします');
}
}
// フォルダ未選択 or 書き込み失敗 → ダウンロード
const headers=['txid','datetime','date','hour','dow','month','product_id','product','category','price','quantity','amount','tax','discount_pct','customer_type','age_band','note'];
const csv=[headers.join(','),...dayRows.map(r=>headers.map(h=>{
const v=r[h]!==undefined?r[h]:'';
const sv=String(v);
return sv.includes(',')||sv.includes('"')||sv.includes('\n')?`"${sv.replace(/"/g,'""')}"`:sv;
}).join(','))].join('\n');
const blob=new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='MISE_sales_'+dateStr+'.csv';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),5000);
sfxSave();
showToast(`📥 ${dateStr} のCSV(${dayRows.length}件)をダウンロードしました`);
}
// CSVを読み込んで売上データに追加(重複txidは上書き)
function importHistoryCSV(input){
const file=input.files[0];
if(!file){return;}
const reader=new FileReader();
reader.onload=e=>{
try{
const text=e.target.result.replace(/^\uFEFF/,''); // BOM除去
const lines=text.split(/\r?\n/).filter(l=>l.trim());
if(lines.length<2){sfxError();showToast('⚠️ 有効なデータがありません');return;}
const headers=splitCSVLine(lines[0]);
const required=['txid','datetime','date','amount'];
const missing=required.filter(h=>!headers.includes(h));
if(missing.length){sfxError();showToast('⚠️ 列が不足: '+missing.join(','));return;}
const newRows=[];
for(let i=1;i<lines.length;i++){
const cells=splitCSVLine(lines[i]);
if(cells.length<2) continue;
const row={};
headers.forEach((h,idx)=>{row[h]=cells[idx]!==undefined?cells[idx]:''});
// 数値変換
['hour','dow','month','price','quantity','amount','tax','discount_pct'].forEach(k=>{
if(row[k]!==undefined&&row[k]!==''){const n=parseFloat(row[k]);if(!isNaN(n))row[k]=n;}
});
if(!row.txid||!row.date) continue;
newRows.push(row);
}
if(!newRows.length){sfxError();showToast('⚠️ 有効な行がありませんでした');return;}
// 読み込んだCSVに含まれるtxidを全て既存から削除してから追加(上書き)
const importTxIds=new Set(newRows.map(r=>r.txid));
sales=sales.filter(r=>!importTxIds.has(r.txid));
sales.push(...newRows);
// 日付・時刻でソート
sales.sort((a,b)=>(a.datetime||a.date||'').localeCompare(b.datetime||b.date||''));
localStorage.setItem('mise_sales',JSON.stringify(sales));
sfxSave();
showToast(`📂 ${newRows.length}件を読み込みました(${importTxIds.size}取引)`);
if(newRows[0]&&newRows[0].date){
historyViewDate=newRows[0].date;
document.getElementById('history-date').value=historyViewDate;
}
renderHistoryList();
if(csvDirHandle) autoSaveSettingToFolder().catch(()=>{});
}catch(err){
sfxError();
showToast('⚠️ 読込エラー: '+err.message);
}
// inputリセット(同じファイルを再選択できるように)
input.value='';
};
reader.readAsText(file,'UTF-8');
}
// CSVの1行をパース(ダブルクォート対応)
function splitCSVLine(line){
const res=[];let cur='',inQ=false;
for(let i=0;i<line.length;i++){
const c=line[i];
if(c==='"'){
if(inQ&&line[i+1]==='"'){cur+='"';i++;}
else{inQ=!inQ;}
} else if(c===','&&!inQ){
res.push(cur);cur='';
} else {
cur+=c;
}
}
res.push(cur);
return res;
}
// ═══════════════════════════════════════════════════════
// ─── File System Access API ──────────────────────────
// ═══════════════════════════════════════════════════════
// フォルダ選択(設定から呼び出し)
async function selectCsvFolder(){
if(!('showDirectoryPicker' in window)){
showToast('⚠️ このブラウザはFile System Access APIに対応していません');
return;
}
try{
const handle = await window.showDirectoryPicker({mode:'readwrite'});
csvDirHandle = handle;
await saveHandle(handle); // IndexedDBに保存して永続化
localStorage.setItem('mise_csv_folder_name', handle.name);
showToast('📂 フォルダを設定しました: ' + handle.name);
sfxSave();
// 設定モーダル内の表示を更新
const disp = document.getElementById('set-folder-display');
if(disp){
disp.textContent = '📂 ' + handle.name;
disp.style.color = 'var(--mint)';
}
// setting.txtを新フォルダに即保存
await autoSaveSettingToFolder();
hideFolderBanner();
}catch(e){
if(e.name !== 'AbortError'){
sfxError();
showToast('⚠️ フォルダ選択エラー: ' + e.message);
}
}
}
// フォルダへのファイル書き込みユーティリティ
// @returns {boolean} 成功したかどうか
async function writeToDirHandle(dirHandle, fileName, textContent){
try{
const fileHandle = await dirHandle.getFileHandle(fileName, {create:true});
const writable = await fileHandle.createWritable();
await writable.write(textContent);
await writable.close();
return true;
}catch(e){
console.error('writeToDirHandle error:', e);
return false;
}
}
// フォルダからファイルを読み込むユーティリティ
// @returns {string|null} ファイルの内容(エラー時はnull)
async function readFromDirHandle(dirHandle, fileName){
try{
const fileHandle = await dirHandle.getFileHandle(fileName, {create:false});
const file = await fileHandle.getFile();
return await file.text();
}catch(e){
if(e.name === 'NotFoundError') return null; // ファイルが無い
console.error('readFromDirHandle error:', e);
return null;
}
}
// setting.txt をフォルダに自動保存(サイレント)
async function autoSaveSettingToFolder(){
if(!csvDirHandle) return;
try{
const settings = collectSettings();
const json = JSON.stringify(settings, null, 2);
const ok = await writeToDirHandle(csvDirHandle, 'setting.txt', json);
if(!ok) console.warn('setting.txt の自動保存に失敗しました');
}catch(e){
console.warn('autoSaveSettingToFolder:', e);
}
}
// 日別CSVをフォルダに書き込む(決済後・CSV保存ボタン押下時に呼ぶ)
// @param {string} dateStr YYYY-MM-DD
async function saveDailyCSVToFolder(dateStr){
if(!csvDirHandle) return false;
const dayRows = sales.filter(r=>r.date===dateStr);
if(!dayRows.length) return false;
const headers=['txid','datetime','date','hour','dow','month','product_id','product','category','price','quantity','amount','tax','discount_pct','customer_type','age_band','note'];
const csv=[headers.join(','),...dayRows.map(r=>headers.map(h=>{
const v=r[h]!==undefined?r[h]:'';
const sv=String(v);
return sv.includes(',')||sv.includes('"')||sv.includes('\n')?`"${sv.replace(/"/g,'""')}"`:sv;
}).join(','))].join('\n');
const fileName = 'MISE_sales_' + dateStr + '.csv';
const ok = await writeToDirHandle(csvDirHandle, fileName, '\uFEFF' + csv);
return ok;
}
// 起動時に前回フォルダからsetting.txtを読み込む案内バナーを表示
function showFolderBanner(folderName){
// 既存バナーがあれば削除
const old = document.getElementById('folder-banner');
if(old) old.remove();
const banner = document.createElement('div');
banner.id = 'folder-banner';
banner.style.cssText = 'background:linear-gradient(90deg,var(--sky),#3a9de0);color:#fff;padding:8px 16px;font-size:13px;font-weight:700;z-index:700;display:flex;align-items:center;gap:10px;box-shadow:0 3px 12px rgba(0,0,0,.15);flex-shrink:0;';
banner.innerHTML = `
<span>📂 前回のフォルダ「<b>${escHtml(folderName)}</b>」を再接続すると全データを自動読み込みします</span>
<button onclick="reconnectFolder()" style="border:none;background:rgba(255,255,255,.25);color:#fff;border-radius:8px;padding:4px 12px;font-size:12px;font-weight:800;cursor:pointer;font-family:inherit;">📂 フォルダを再接続</button>
<button onclick="hideFolderBanner()" style="border:none;background:none;color:rgba(255,255,255,.7);font-size:18px;cursor:pointer;margin-left:auto;line-height:1;">✕</button>
`;
document.body.insertBefore(banner, document.body.firstChild);
}
function hideFolderBanner(){
const b = document.getElementById('folder-banner');
if(b) b.remove();
}
// フォルダを再接続(バナーのボタンから呼ぶ)
async function reconnectFolder(){
const storedHandle = await loadHandle().catch(()=>null);
if(storedHandle){
try {
// ユーザー操作のタイミングで権限を要求
if((await storedHandle.requestPermission({mode:'readwrite'})) === 'granted'){
csvDirHandle = storedHandle;
localStorage.setItem('mise_csv_folder_name', csvDirHandle.name);
hideFolderBanner();
await loadSettingFromFolder();
showToast('📂 フォルダから設定と売上データを読み込みました');
return;
}
} catch(e) {
console.warn('Permission request failed:', e);
}
}
// 保存されたハンドルが無い、または権限要求に失敗した場合は再選択させる
await selectCsvFolder();
if(csvDirHandle){
await loadSettingFromFolder();
}
}
// フォルダから setting.txt を読み込んで設定を反映
async function loadSettingFromFolder(){
if(!csvDirHandle) return;
const text = await readFromDirHandle(csvDirHandle, 'setting.txt');
if(!text){ return; } // ファイルなし=初回利用
try{
const settings = JSON.parse(text);
applySettings(settings);
showToast('📂 データをフォルダから自動読み込みしました');
}catch(e){
console.warn('loadSettingFromFolder parse error:', e);
}
}
// ═══════════════════════════════════════════════════════
// ─── setting.txt 保存・読み込み ──────────────────────────
// ═══════════════════════════════════════════════════════
function collectSettings(){
return {
version: 2,
storename: localStorage.getItem('mise_storename')||'',
csv_folder_name: csvDirHandle ? csvDirHandle.name : (localStorage.getItem('mise_csv_folder_name')||''),
txcounter: txCounter,
products: products,
sales: sales, // 追加: 売上データを含める
saved_at: new Date().toISOString(),
};
}
// 設定オブジェクトをアプリに反映(localStorageにも保存)
function applySettings(settings){
if(!settings||typeof settings!=='object') return;
if(settings.storename !== undefined){
localStorage.setItem('mise_storename', settings.storename);
const storenameEl = document.getElementById('set-storename');
if(storenameEl) storenameEl.value = settings.storename;
}
if(settings.csv_folder_name !== undefined && settings.csv_folder_name){
localStorage.setItem('mise_csv_folder_name', settings.csv_folder_name);
}
if(settings.txcounter !== undefined){
const tc = parseInt(settings.txcounter);
if(!isNaN(tc) && tc > 0){
txCounter = tc;
localStorage.setItem('mise_txcounter', String(txCounter));
updateTxLabel();
}
}
if(Array.isArray(settings.products) && settings.products.length > 0){
products = settings.products;
localStorage.setItem('mise_products', JSON.stringify(products));
renderCatBar();
renderProducts();
}
// 追加: 売上データの復元
if(Array.isArray(settings.sales)){
sales = settings.sales;
localStorage.setItem('mise_sales', JSON.stringify(sales));
if(document.getElementById('panel-history').style.display!=='none'){
renderHistoryList();
}
}
}
// localStorageに保存された前回の設定を起動時に反映
function applyStoredSettings(){
// txcounterはすでにlet宣言時に読み込み済みだが念のため再確認
const tc = parseInt(localStorage.getItem('mise_txcounter')||'1');
if(!isNaN(tc) && tc >= 1){ txCounter = tc; updateTxLabel(); }
// productsやsalesはすでに読み込み済み
}
// 手動で setting.txt を保存(設定画面から呼び出し)
async function saveSettingTxtManual(){
const settings = collectSettings();
const json = JSON.stringify(settings, null, 2);
if(csvDirHandle){
// フォルダが選択されていればフォルダへ書き込み
const ok = await writeToDirHandle(csvDirHandle, 'setting.txt', json);
if(ok){
sfxSave();
showToast('💾 データをフォルダにバックアップしました');
return;
} else {
showToast('⚠️ フォルダへの書き込みに失敗。ダウンロードします');
}
}
// フォルダ未選択またはフォルダ書き込み失敗 → ダウンロード
const blob = new Blob([json], {type:'application/json;charset=utf-8'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'setting.txt';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href), 5000);
sfxSave();
showToast('💾 データをダウンロードしました');
}
function loadSettingTxt(input){
const file = input.files[0];
if(!file){ return; }
const reader = new FileReader();
reader.onload = e => {
try{
const settings = JSON.parse(e.target.result);
if(!settings||typeof settings!=='object'){ throw new Error('不正なフォーマット'); }
applySettings(settings);
sfxSave();
showToast('📂 データを復元しました');
closeModal();
setTimeout(openSettings, 200);
}catch(err){
sfxError();
showToast('⚠️ 読込エラー: ' + err.message);
}
input.value = '';
};
reader.readAsText(file, 'UTF-8');
}
// ─── ページ離脱時: setting.txt をフォルダへ自動保存 ─────────
window.addEventListener('beforeunload', ()=>{
// localStorage に最終設定を保存(同期)
localStorage.setItem('mise_txcounter', String(txCounter));
localStorage.setItem('mise_last_saved', new Date().toISOString());
// フォルダが選択されている場合は非同期で試みる(保証はできないがChrome系では動作)
if(csvDirHandle){
autoSaveSettingToFolder().catch(()=>{});
}
});
// 30秒ごとに自動保存(フォルダが設定されている場合)
setInterval(async ()=>{
if(csvDirHandle){
await autoSaveSettingToFolder();
// 今日の日別CSVも更新
if(sales.filter(r=>r.date===todayStr()).length > 0){
await saveDailyCSVToFolder(todayStr());
}
}
}, 30000);
function showModal(title, body, extra, buttons){
document.getElementById('modal-title').innerHTML=title;
document.getElementById('modal-body').innerHTML=body+extra;
document.getElementById('modal-btns').innerHTML=buttons.map((b,i)=>`
<button class="${b.cls}" id="mbtn-${i}">${b.label}</button>`).join('');
buttons.forEach((b,i)=>document.getElementById('mbtn-'+i).onclick=b.action);
document.getElementById('modal').classList.remove('hidden');
}
function closeModal(){document.getElementById('modal').classList.add('hidden');}
document.getElementById('modal').addEventListener('click',e=>{
if(e.target===document.getElementById('modal')) closeModal();
});
// ─── トースト ─────────────────────────────────────────────
let toastTimer;
function showToast(msg){
const t=document.getElementById('toast');
t.textContent=msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>t.classList.remove('show'),2200);
}
// ─── 起動 ─────────────────────────────────────────────────
init();
</script>
</body>
</html>

コメント