const state = {
page: 'order',
ws: null,
online: false,
data: null,
menu: [],
menuEtag: null,
cart: [],
settingsTab: 'main',
callList: [],
memory: null,
archived: {
sessionId: null,
orders: [],
loading: false,
error: null,
fetched: false
},
salesSummary: {
data: null,
sessionId: null,
fetchedAt: null,
loading: false,
error: null
}
};
let _reloadTimer = null;
function scheduleStateReload() {
if (_reloadTimer) {
return;
}
_reloadTimer = setTimeout(async () => {
_reloadTimer = null;
try {
await loadStateData();
} catch (error) {
console.error('状態再取得スケジュール失敗:', error);
}
}, 400);
}
function renderMenu() {
if (!state.data) {
return;
}
state.data.menu = state.menu;
if (state.page === 'order' || state.page === 'settings') {
render();
}
}
async function loadMenu(options = {}) {
const { force = false } = options;
try {
const headers = {};
if (!force && state.menuEtag) {
headers['If-None-Match'] = state.menuEtag;
}
const response = await fetch('/api/menu', {
method: 'GET',
headers,
cache: 'no-cache'
});
if (response.status === 304) {
if (state.data) {
state.data.menu = state.menu;
}
return false;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const etag = response.headers.get('ETag');
if (etag) {
state.menuEtag = etag;
}
const payload = await response.json();
state.menu = Array.isArray(payload.menu) ? payload.menu : [];
if (state.data) {
state.data.menu = state.menu;
renderMenu();
}
console.log('メニュー取得完了:', { count: state.menu.length, etag: state.menuEtag });
return true;
} catch (error) {
console.error('loadMenu failed', error);
return false;
}
}
async function appInit() {
try {
try {
await syncTimeOnce();
} catch (error) {
console.warn('初期時刻同期に失敗しましたが継続します:', error);
}
await loadMenu({ force: true });
await loadStateData();
fetchSalesSummary(true);
} finally {
connectWs();
}
}
function getOrderFromState(orderNo) {
if (!orderNo) {
return null;
}
if (state.data && Array.isArray(state.data.orders)) {
const activeOrder = state.data.orders.find(order => order && order.orderNo === orderNo);
if (activeOrder) {
return { order: activeOrder, source: 'active' };
}
}
if (state.archived && Array.isArray(state.archived.orders)) {
const archivedOrder = state.archived.orders.find(order => order && order.orderNo === orderNo);
if (archivedOrder) {
return { order: archivedOrder, source: 'archived' };
}
}
return null;
}
let memoryMonitorTimer = null;
const app = document.getElementById('app');
const offlineModal = document.getElementById('offline-modal');
const reconnectBtn = document.getElementById('reconnect-btn');
const statusIndicator = document.getElementById('connection-status');
document.addEventListener('DOMContentLoaded', () => {
console.log('KDS PWA init');
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('Service Worker registered', reg))
.catch(err => console.error('Service Worker registration failed', err));
}
setupNavigation();
setInterval(syncTimeOnce, 5 * 60 * 1000);
appInit().catch(err => console.error('初期化処理に失敗しました:', err));
reconnectBtn.addEventListener('click', connectWs);
window.addEventListener('error', e => console.error('GLOBAL ERR', e.error || e.message));
window.addEventListener('unhandledrejection', e => console.error('PROMISE REJECTION', e.reason));
document.addEventListener('click', ev => {
const btn = ev.target.closest("[data-action='confirm-order']");
if (!btn) {
return;
}
ev.preventDefault();
if (btn.dataset.loading === '1') {
return;
}
btn.dataset.loading = '1';
submitOrder().catch(console.error).finally(() => {
delete btn.dataset.loading;
});
});
render();
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
setInterval(() => {
if (state.page === 'call') {
loadCallList();
}
}, 10000);
loadCallList();
});
function updateCurrentTime() {
const timeDiv = document.getElementById('current-time');
if (timeDiv) {
const now = new Date();
timeDiv.textContent = now.toLocaleString('ja-JP', {
timeZone: 'Asia/Tokyo',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
const callTimeDiv = document.querySelector('.call-time');
if (callTimeDiv) {
const now = new Date();
callTimeDiv.textContent = now.toLocaleString('ja-JP', {
timeZone: 'Asia/Tokyo',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
}
async function loadStateData(options = {}) {
const { forceFull = false } = options;
try {
const preferLight = !forceFull && state.data !== null;
const url = preferLight ? '/api/state?light=1' : '/api/state';
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
state.data = payload;
if (Array.isArray(payload.menu)) {
state.menu = payload.menu;
}
state.data.menu = state.menu;
console.log('状態データ取得完了:', state.data);
const needsSalesData = state.settingsTab === 'sales';
const sessionId = state.data?.session?.sessionId || null;
if (needsSalesData && sessionId) {
await loadArchivedOrders(sessionId, true);
ensureSalesSummary(true);
} else {
ensureSalesSummary(false);
}
render();
updateConfirmOrderButton();
} catch (error) {
console.error('状態データ取得エラー:', error);
}
}
async function loadArchivedOrders(sessionId, force = false) {
if (!sessionId) {
state.archived.sessionId = null;
state.archived.orders = [];
state.archived.loading = false;
state.archived.error = null;
state.archived.fetched = false;
return;
}
if (state.archived.sessionId && state.archived.sessionId !== sessionId) {
state.archived.fetched = false;
state.archived.orders = [];
}
if (!force && state.archived.fetched && state.archived.sessionId === sessionId) {
return;
}
if (state.archived.loading) {
return;
}
state.archived.loading = true;
state.archived.error = null;
state.archived.sessionId = sessionId;
if (state.settingsTab === 'sales') {
render();
}
try {
const response = await fetch(`/api/orders/archive?sessionId=${encodeURIComponent(sessionId)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
state.archived.sessionId = data.sessionId || sessionId;
state.archived.orders = Array.isArray(data.orders) ? data.orders : [];
state.archived.error = null;
state.archived.fetched = true;
} catch (error) {
console.error('アーカイブ注文取得エラー:', error);
state.archived.orders = [];
state.archived.error = error.message || 'unknown';
state.archived.fetched = false;
} finally {
state.archived.loading = false;
if (state.settingsTab === 'sales') {
render();
}
const widget = document.getElementById('completed-orders-widget');
if (widget && widget.style.display !== 'none') {
loadCompletedOrders();
}
}
}
function ensureArchivedOrders(force = false) {
if (!state.data || !state.data.session) {
return;
}
const sessionId = state.data.session.sessionId;
if (!sessionId) {
return;
}
if (state.archived.loading) {
return;
}
if (!force && state.archived.fetched && state.archived.sessionId === sessionId) {
return;
}
loadArchivedOrders(sessionId, force);
}
function ensureSalesSummary(force = false) {
if (!state.data || !state.data.session) {
return;
}
const sessionId = state.data.session.sessionId;
if (!sessionId) {
return;
}
if (state.salesSummary.loading) {
return;
}
const hasCurrentSummary = state.salesSummary.data && state.salesSummary.sessionId === sessionId;
if (!force && hasCurrentSummary) {
return;
}
fetchSalesSummary(force);
}
async function fetchSalesSummary(force = false) {
if (state.salesSummary.loading) {
return;
}
state.salesSummary.loading = true;
state.salesSummary.error = null;
if (state.settingsTab === 'sales') {
render();
}
try {
const params = [];
if (force) {
params.push('rebuild=1');
}
params.push(`ts=${Date.now()}`);
const url = `/api/sales/summary?${params.join('&')}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
state.salesSummary.data = data;
if (data && data.sessionId) {
state.salesSummary.sessionId = data.sessionId;
} else if (state.data && state.data.session) {
state.salesSummary.sessionId = state.data.session.sessionId;
} else {
state.salesSummary.sessionId = null;
}
state.salesSummary.fetchedAt = Date.now();
} catch (error) {
console.error('売上サマリ取得エラー:', error);
state.salesSummary.error = error && error.message ? error.message : 'unknown';
} finally {
state.salesSummary.loading = false;
if (state.settingsTab === 'sales') {
render();
}
}
}
async function syncTimeOnce() {
const now = new Date();
const epoch = Math.floor(now.getTime() / 1000);
console.log('時刻同期開始:', {
localTime: now.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }),
epoch: epoch,
iso: now.toISOString()
});
try {
const response = await fetch('/api/time/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ epoch })
});
if (response.ok) {
console.log('時刻同期完了:', now.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }));
} else {
console.error('時刻同期失敗: HTTP', response.status);
}
} catch (e) {
console.error('時刻同期エラー:', e);
}
}
function setupNavigation() {
const navBtns = document.querySelectorAll('.nav-btn');
navBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const page = e.target.dataset.page;
if (page) {
navBtns.forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
state.page = page;
if (page === 'call') {
loadCallList().then(() => render());
} else {
render();
}
}
});
});
document.querySelector(`[data-page="${state.page}"]`).classList.add('active');
}
function navigateTo(page) {
state.page = page;
const navBtns = document.querySelectorAll('.nav-btn');
navBtns.forEach(btn => {
if (btn.dataset.page === page) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
if (page === 'call') {
loadCallList().then(() => render());
} else {
render();
}
}
function connectWs() {
const wsUrl = `ws://${location.host}/ws`;
console.log('WebSocket接続試行:', wsUrl);
state.ws = new WebSocket(wsUrl);
state.ws.onopen = () => {
console.log('WebSocket接続成功');
updateOnlineStatus(true);
};
state.ws.onclose = () => {
console.log('WebSocket接続切断');
updateOnlineStatus(false);
setTimeout(connectWs, 3000);
};
state.ws.onerror = (error) => {
console.error('WebSocket エラー:', error);
updateOnlineStatus(false);
};
state.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('WebSocket メッセージ受信:', data);
if (data.type === 'hello') {
console.log('サーバーから挨拶:', data.msg);
} else if (data.type === 'sync.snapshot') {
loadMenu();
scheduleStateReload();
} else if (data.type === 'system.reset') {
loadMenu({ force: true });
scheduleStateReload();
} else if (data.type === 'order.created' || data.type === 'order.updated') {
scheduleStateReload();
} else if (data.type === 'printer.status') {
if (state.data) {
state.data.printer.paperOut = data.paperOut !== undefined ? data.paperOut : state.data.printer.paperOut;
state.data.printer.holdJobs = data.holdJobs !== undefined ? data.holdJobs : state.data.printer.holdJobs;
render();
updateConfirmOrderButton();
}
} else if (data.type === 'order.cooked') {
const exists = state.callList.find(item => item.orderNo === data.orderNo);
if (!exists) {
state.callList.push({ orderNo: data.orderNo, ts: Date.now() / 1000 });
console.log('呼び出しリストに追加:', data.orderNo);
}
if (state.page === 'call') {
updateCallScreen();
} else {
scheduleStateReload();
}
} else if (data.type === 'order.picked') {
const beforeLength = state.callList.length;
state.callList = state.callList.filter(item => item.orderNo !== data.orderNo);
if (beforeLength !== state.callList.length) {
console.log('呼び出しリストから削除:', data.orderNo);
}
if (state.page === 'call') {
updateCallScreen();
} else {
scheduleStateReload();
}
}
} catch (err) {
console.error('メッセージ解析エラー:', err);
}
};
}
function updateOnlineStatus(online) {
state.online = online;
statusIndicator.className = `status-indicator ${online ? 'online' : 'offline'}`;
if (online) {
offlineModal.classList.add('hidden');
} else {
offlineModal.classList.remove('hidden');
}
}
function updatePaperOutModal() {
const modal = document.getElementById('paper-out-modal');
if (state.data && state.data.printer.paperOut) {
if (!modal) {
const modalHtml = `
<div id="paper-out-modal" class="modal">
<div class="modal-content">
<h3>⚠️ プリンタ用紙切れ</h3>
<p>用紙を交換してください。注文受付は一時停止しています。</p>
<button id="paper-replaced-btn" class="btn btn-primary">用紙交換済み</button>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
document.getElementById('paper-replaced-btn').addEventListener('click', async () => {
try {
await fetch('/api/printer/paper-replaced', { method: 'POST' });
document.getElementById('paper-out-modal').remove();
} catch (error) {
console.error('用紙交換通知エラー:', error);
}
});
}
} else if (modal) {
modal.remove();
}
}
function render() {
let content = '';
switch (state.page) {
case 'order':
content = renderOrderPage();
break;
case 'kitchen':
content = renderKitchenPage();
break;
case 'pickup':
content = renderPickupPage();
break;
case 'settings':
content = renderSettingsPage();
break;
case 'export':
content = renderExportPage();
break;
case 'call':
content = renderCallPage();
break;
default:
content = '<div class="card"><h2>ページが見つかりません</h2></div>';
}
app.innerHTML = content;
const nav = document.querySelector('nav.nav');
if (nav) {
if (state.page === 'call') {
nav.style.display = 'none';
} else {
nav.style.display = 'flex';
}
}
updatePaperOutModal();
setupPageEvents();
}
function renderOrderPage() {
if (!state.data) {
return '<div class="card"><h2>📱 注文受付</h2><p>データ読込中...</p></div>';
}
const mainItems = state.data.menu.filter(item => item.category === 'MAIN' && item.active);
const sideItems = state.data.menu.filter(item => item.category === 'SIDE' && item.active);
const cookingOrders = state.data.orders.filter(order => order.status === 'COOKING');
const paperWarning = state.data.printer.paperOut ?
'<div class="card" style="border-left-color: #dc3545 ;"><h3>⚠️ 注文受付停止中</h3><p>プリンタ用紙切れのため、注文を受け付けできません。</p></div>' : '';
return `
${paperWarning}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>新規注文</h2>
<button class="btn btn-info" onclick="toggleCompletedOrders()" id="toggle-completed-btn">
📋 キャンセル・再印刷
</button>
</div>
<!-- 注文済み一覧ウィジェット -->
<div id="completed-orders-widget" class="completed-orders-widget" style="display: none;">
<h3>📋 注文済み一覧</h3>
<div id="completed-orders-list" class="completed-orders-list">
<!-- 動的に生成 -->
</div>
</div>
<!-- メイン選択 -->
<h3>メイン商品</h3>
<div class="grid">
${mainItems.map(item => {
const normalPrice = item.price_normal;
const presalePrice = item.price_normal + item.presale_discount_amount;
const hasPresale = state.data.settings.presaleEnabled && item.presale_discount_amount < 0;
const hasSides = sideItems.length > 0;
return `
<div class="card">
<h4>${item.name}</h4>
<p style="margin: 5px 0;">通常: ${normalPrice}円</p>
${hasPresale ? `<p style="margin: 5px 0;">前売: ${presalePrice}円</p>` : ''}
<!-- 単品ボタン -->
<button class="btn btn-primary" onclick="addMainSingle('${item.sku}', 'normal')"
${state.data.printer.paperOut ? 'disabled' : ''}
style="width: 100%; margin-bottom: 5px;">
通常
</button>
${hasPresale ? `
<button class="btn btn-success" onclick="addMainSingle('${item.sku}', 'presale')"
${state.data.printer.paperOut ? 'disabled' : ''}
style="width: 100%; margin-bottom: 5px;">
前売り
</button>` : ''}
<!-- セットボタン -->
${hasSides ? `
<button class="btn btn-warning" onclick="showSideSelectModal('${item.sku}', 'normal')"
${state.data.printer.paperOut ? 'disabled' : ''}
style="width: 100%; margin-bottom: 5px;">
セット(通常)
</button>
${hasPresale ? `
<button class="btn btn-info" onclick="showSideSelectModal('${item.sku}', 'presale')"
${state.data.printer.paperOut ? 'disabled' : ''}
style="width: 100%; margin-bottom: 5px;">
セット(前売り)
</button>` : ''}
` : '<p style="color: #999 ; font-size: 0.9em;">※サイド商品なし</p>'}
</div>
`}).join('')}
</div>
<!-- サイド単品 -->
<h3>サイド単品</h3>
<div class="grid">
${sideItems.map(item => `
<div class="card">
<h4>${item.name}</h4>
<p>単品: ${item.price_single}円</p>
<button class="btn btn-secondary" onclick="addToCart('SIDE_SINGLE', '${item.sku}')" ${state.data.printer.paperOut ? 'disabled' : ''}>
追加
</button>
</div>
`).join('')}
</div>
<!-- カート -->
<div class="card">
<h3>注文カート</h3>
<div id="cart-items"></div>
<div style="margin-top: 15px;">
<button id="confirm-order-btn" class="btn btn-success btn-large"
data-action="confirm-order"
type="button"
onclick="handleConfirmOrder(event)"
${state.cart.length === 0 || state.data.printer.paperOut ? 'disabled' : ''}
style="font-size: 1.5em; padding: 15px 30px; width: 100%; margin-bottom: 10px;">
📝 注文確定
</button>
<button class="btn btn-secondary" onclick="clearCart()" style="width: 100%;">
🗑️ カートクリア
</button>
<div style="margin-top: 10px; padding: 8px; background: #f8f9fa ; border-radius: 5px; font-size: 0.85em; color: #666 ; text-align: center;">
💡 注文確定ボタンをクリックすると即座に注文が送信されます(画面遷移不要)
</div>
</div>
</div>
</div>
`;
}
function renderKitchenPage() {
if (!state.data) {
return '<div class="card"><h2>👨🍳 キッチン表示</h2><p>データ読込中...</p></div>';
}
const cookingOrders = state.data.orders.filter(order => order.status === 'COOKING');
return `
<div class="kitchen-container">
<h2 style="text-align: center; margin-bottom: 20px;">調理一覧 (${cookingOrders.length}件)</h2>
<div class="kitchen-grid">
${cookingOrders.map(order => {
const elapsed = order.ts && order.ts > 946684800 ? Math.floor((Date.now() / 1000 - order.ts) / 60) : 0;
const elapsedColor = elapsed > 10 ? '#dc3545' : elapsed > 5 ? '#ffc107' : '#28a745';
return `
<div class="kitchen-card" onclick="showOrderDetail('${order.orderNo}')">
<div class="kitchen-header">
<h1 style="font-size: 1.8em; margin: 0; color: #333 ;">注文 #${order.orderNo}</h1>
<div class="elapsed-time" style="color: ${elapsedColor}; font-weight: bold; font-size: 1em;">
経過: ${elapsed}分
</div>
</div>
<div class="kitchen-items">
${order.items.map(item => `
<div class="kitchen-item">
<div class="item-name" style="font-size: 1.2em; font-weight: bold;">${item.name}</div>
<div class="item-qty" style="font-size: 1.4em; color: #007bff ; font-weight: bold;">
数量: ${item.qty}個
</div>
${item.priceMode === 'presale' ? '<div class="presale-badge">前売</div>' : ''}
</div>
`).join('')}
</div>
<div class="kitchen-footer">
<div class="kitchen-note" style="color: #666 ; font-size: 0.8em; text-align: center;">
調理完了後は品出し画面で操作してください
</div>
</div>
</div>
`;
}).join('')}
</div>
${cookingOrders.length === 0 ?
'<div class="no-orders" style="text-align: center; font-size: 1.5em; color: #666 ; margin-top: 50px;">調理待ちの注文はありません</div>' :
''}
</div>
`;
}
function renderPickupPage() {
if (!state.data) {
return '<div class="card"><h2>📦 品出し管理</h2><p>データ読込中...</p></div>';
}
const pickupOrders = state.data.orders
.filter(order => order.status === 'COOKING' || order.status === 'DONE')
.sort((a, b) => b.ts - a.ts)
.slice(0, 30);
return `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2>品出し画面 (${pickupOrders.length}件)</h2>
</div>
<div class="grid">
${pickupOrders.map(order => {
const elapsed = order.ts && order.ts > 946684800 ? Math.floor((Date.now() / 1000 - order.ts) / 60) : 0;
const elapsedColor = elapsed > 15 ? '#dc3545' : elapsed > 10 ? '#ffc107' : '#28a745';
const itemsSummary = order.items.slice(0, 3).map(item =>
`${item.name}(${item.unitPriceApplied || item.unitPrice || 0}円)`
).join(', ') + (order.items.length > 3 ? '...' : '');
const isCooking = order.status === 'COOKING';
const statusText = isCooking ? '調理中' : '調理完了';
const statusColor = isCooking ? '#ffc107' : '#28a745';
const actionText = isCooking ? '品出し完了' : '品出し完了';
return `
<div class="card order-card pickup-card"
data-order-no="${order.orderNo}"
style="border-left-color: ${statusColor}; background-color: #f8f9fa ; cursor: pointer;">
<div class="pickup-header">
<h3># ${order.orderNo}</h3>
<span class="badge" style="background-color: ${statusColor};">${statusText}</span>
</div>
<div class="pickup-timing" style="color: ${elapsedColor}; font-weight: bold;">
${isCooking ? '調理開始から' : '完了から'}${elapsed}分経過
</div>
<div class="pickup-items">
<strong>${itemsSummary}</strong>
</div>
<div class="pickup-actions" style="text-align: center; margin-top: 15px;">
<button class="btn btn-primary btn-large"
onclick="showOrderDetail('${order.orderNo}')"
style="font-size: 1.4em; padding: 15px 30px; width: 100%;">
📋 状態を変更する
</button>
<small style="display: block; margin-top: 8px; color: #666 ; font-size: 0.95em;">
👆 クリックして調理済み・品出し済みにできます
</small>
</div>
</div>
`;
}).join('')}
</div>
${pickupOrders.length === 0 ? '<p>品出し待ちの注文はありません</p>' : ''}
</div>
`;
}
function renderSettingsPage() {
if (!state.data) {
return '<div class="card"><h2>⚙️ システム設定</h2><p>データ読込中...</p></div>';
}
const tabNav = `
<nav class="nav" style="margin-bottom: 20px;">
<button class="nav-btn ${state.settingsTab === 'main' ? 'active' : ''}" onclick="switchSettingsTab('main')">メイン商品</button>
<button class="nav-btn ${state.settingsTab === 'side' ? 'active' : ''}" onclick="switchSettingsTab('side')">サイド商品</button>
<button class="nav-btn ${state.settingsTab === 'system' ? 'active' : ''}" onclick="switchSettingsTab('system')">システム設定</button>
<button class="nav-btn ${state.settingsTab === 'sales' ? 'active' : ''}" onclick="switchSettingsTab('sales')">売上確認</button>
<button class="nav-btn ${state.settingsTab === 'chinchiro' ? 'active' : ''}" onclick="switchSettingsTab('chinchiro')">ちんちろ</button>
<button class="nav-btn ${state.settingsTab === 'qrprint' ? 'active' : ''}" onclick="switchSettingsTab('qrprint')">プリント設定</button>
</nav>
`;
let tabContent = '';
if (state.settingsTab === 'main') {
const mainItems = state.data.menu.filter(item => item.category === 'MAIN');
tabContent = `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3>メイン商品管理</h3>
<button class="btn btn-success" onclick="addNewMainItem()">➕ 新規追加</button>
</div>
<div class="menu-items-grid" id="main-items">
${mainItems.map((item, idx) => `
<div class="menu-item-card" data-sku="${item.sku}" data-idx="${idx}">
<div class="menu-item-header">
<div class="drag-handle">⋮⋮</div>
<label class="toggle-switch">
<input type="checkbox" ${item.active ? 'checked' : ''} onchange="toggleMainItemActive('${item.sku}', this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
<div class="menu-item-body">
<div class="form-group">
<label>商品名</label>
<input type="text" class="form-control" value="${item.name}"
onchange="updateMainItem('${item.sku}', 'name', this.value)">
</div>
<div class="form-group">
<label>商品名(ローマ字)</label>
<input type="text" class="form-control" value="${item.nameRomaji || ''}"
onchange="updateMainItem('${item.sku}', 'nameRomaji', this.value)">
</div>
<div class="form-row">
<div class="form-group">
<label>通常価格</label>
<input type="number" class="form-control" value="${item.price_normal}"
onchange="updateMainItem('${item.sku}', 'price_normal', parseInt(this.value))">
</div>
<div class="form-group">
<label>前売割引額</label>
<input type="number" class="form-control" value="${item.presale_discount_amount}"
onchange="updateMainItem('${item.sku}', 'presale_discount_amount', parseInt(this.value))">
</div>
</div>
<small class="text-muted">SKU: ${item.sku}</small>
</div>
</div>
`).join('')}
</div>
</div>
`;
} else if (state.settingsTab === 'side') {
const sideItems = state.data.menu.filter(item => item.category === 'SIDE');
tabContent = `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3>サイド商品管理</h3>
<button class="btn btn-success" onclick="addNewSideItem()">➕ 新規追加</button>
</div>
<div class="menu-items-grid" id="side-items">
${sideItems.map((item, idx) => `
<div class="menu-item-card" data-sku="${item.sku}" data-idx="${idx}">
<div class="menu-item-header">
<div class="drag-handle">⋮⋮</div>
<label class="toggle-switch">
<input type="checkbox" ${item.active ? 'checked' : ''} onchange="toggleSideItemActive('${item.sku}', this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
<div class="menu-item-body">
<div class="form-group">
<label>商品名</label>
<input type="text" class="form-control" value="${item.name}"
onchange="updateSideItem('${item.sku}', 'name', this.value)">
</div>
<div class="form-group">
<label>商品名(ローマ字)</label>
<input type="text" class="form-control" value="${item.nameRomaji || ''}"
onchange="updateSideItem('${item.sku}', 'nameRomaji', this.value)">
</div>
<div class="form-row">
<div class="form-group">
<label>単品価格</label>
<input type="number" class="form-control" value="${item.price_single}"
onchange="updateSideItem('${item.sku}', 'price_single', parseInt(this.value))">
</div>
<div class="form-group">
<label>セット時価格</label>
<input type="number" class="form-control" value="${item.price_as_side}"
onchange="updateSideItem('${item.sku}', 'price_as_side', parseInt(this.value))">
</div>
</div>
<small class="text-muted">SKU: ${item.sku}</small>
</div>
</div>
`).join('')}
</div>
</div>
`;
} else if (state.settingsTab === 'system') {
tabContent = `
<div class="card">
<div class="settings-header">
<div>
<h3>システム設定</h3>
<p class="settings-subtitle">KDSの基本設定を管理します</p>
</div>
<div class="settings-actions">
<button class="btn btn-primary" onclick="saveSystemSettings()">💾 システム設定を保存</button>
</div>
</div>
<div class="settings-panel">
<section class="settings-section">
<h4>前売り機能</h4>
<p class="settings-note">無効にすると注文画面で前売りボタンが非表示になります</p>
<label class="settings-toggle">
<input type="checkbox" ${state.data.settings.presaleEnabled ? 'checked' : ''} id="presale-enabled">
<span>前売り機能を有効にする</span>
</label>
</section>
<section class="settings-section">
<h4>店舗情報</h4>
<div class="settings-field">
<label for="store-name">店舗名</label>
<input type="text" value="${state.data.settings.store.name}" id="store-name">
</div>
<div class="settings-field">
<label for="store-name-romaji">店舗名(レシート印刷用ローマ字)</label>
<input type="text" value="${state.data.settings.store.nameRomaji || 'KDS BURGER'}" id="store-name-romaji">
<small>レシート印刷時に使用される英語表記です</small>
</div>
<div class="settings-field">
<label for="register-id">レジスターID</label>
<input type="text" value="${state.data.settings.store.registerId}" id="register-id">
</div>
</section>
<section class="settings-section">
<h4>注文番号設定</h4>
<div class="settings-inline">
<div class="settings-field">
<label for="numbering-min">最小番号</label>
<input type="number" value="${state.data.settings.numbering.min}" id="numbering-min" min="1" max="9999">
</div>
<div class="settings-field">
<label for="numbering-max">最大番号</label>
<input type="number" value="${state.data.settings.numbering.max}" id="numbering-max" min="1" max="9999">
</div>
</div>
</section>
<section class="settings-section settings-danger">
<h4>⚠️ 初期化</h4>
<p class="settings-note">システムを完全に初期化します。全ての注文データ、注文番号カウンタ、設定が削除されます。</p>
<button class="btn btn-danger" onclick="resetSystem()">🔄 システム完全初期化</button>
</section>
</div>
</div>
`;
} else if (state.settingsTab === 'sales') {
if (state.salesSummary.loading) {
tabContent = `
<div class="card">
<h3>売上確認</h3>
<p>売上サマリを読込中...</p>
</div>
`;
} else if (state.salesSummary.error) {
tabContent = `
<div class="card">
<h3>売上確認</h3>
<p style="color:#d32f2f;">売上サマリの取得に失敗しました: ${state.salesSummary.error}</p>
<button class="btn btn-primary" onclick="fetchSalesSummary(true)">再試行</button>
</div>
`;
} else if (state.salesSummary.data) {
const summary = state.salesSummary.data;
const updatedAt = summary.updatedAt ? new Date(summary.updatedAt * 1000).toLocaleString('ja-JP') : '不明';
const confirmed = summary.confirmedOrders || 0;
const cancelled = summary.cancelledOrders || 0;
const totalOrders = summary.totalOrders != null ? summary.totalOrders : confirmed + cancelled;
const netSales = summary.netSales || 0;
const grossSales = summary.grossSales != null ? summary.grossSales : netSales + (summary.cancelledAmount || 0);
const cancelledAmount = summary.cancelledAmount || 0;
const averageOrder = confirmed > 0 ? Math.round(netSales / confirmed) : 0;
const cancelRate = totalOrders > 0 ? ((cancelled / totalOrders) * 100).toFixed(1) : '0.0';
const adjustment = grossSales - netSales;
tabContent = `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 12px;">
<h3 style="margin: 0;">売上確認</h3>
<button class="btn btn-primary" onclick="refreshSalesStats()" style="font-size: 0.95em; padding: 8px 14px;">
🔄 売上データ更新
</button>
</div>
<div class="sales-overview" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin: 20px 0;">
<div class="stat-card" style="background: #e3f2fd ; padding: 15px; border-radius: 8px; text-align: center;">
<h4 style="margin: 0 0 10px 0; color: #1976d2 ;">総注文数</h4>
<div style="font-size: 2em; font-weight: bold; color: #1976d2 ;">${totalOrders.toLocaleString()}</div>
<small style="color: #666 ;">件 (確定 ${confirmed.toLocaleString()} / キャンセル ${cancelled.toLocaleString()})</small>
</div>
<div class="stat-card" style="background: #e8f5e8 ; padding: 15px; border-radius: 8px; text-align: center;">
<h4 style="margin: 0 0 10px 0; color: #388e3c ;">総売上</h4>
<div style="font-size: 2em; font-weight: bold; color: #388e3c ;">¥${netSales.toLocaleString()}</div>
<small style="color: #666 ;">円 (純売上)</small>
</div>
<div class="stat-card" style="background: #fff3e0 ; padding: 15px; border-radius: 8px; text-align: center;">
<h4 style="margin: 0 0 10px 0; color: #f57c00 ;">平均単価</h4>
<div style="font-size: 2em; font-weight: bold; color: #f57c00 ;">¥${averageOrder.toLocaleString()}</div>
<small style="color: #666 ;">円 / 注文</small>
</div>
<div class="stat-card" style="background: #fce4ec ; padding: 15px; border-radius: 8px; text-align: center;">
<h4 style="margin: 0 0 10px 0; color: #c2185b ;">キャンセル額</h4>
<div style="font-size: 2em; font-weight: bold; color: #c2185b ;">¥${cancelledAmount.toLocaleString()}</div>
<small style="color: #666 ;">累計キャンセル金額</small>
</div>
<div class="stat-card" style="background: #ede7f6 ; padding: 15px; border-radius: 8px; text-align: center;">
<h4 style="margin: 0 0 10px 0; color: #5e35b1 ;">キャンセル率</h4>
<div style="font-size: 2em; font-weight: bold; color: #5e35b1 ;">${cancelRate}%</div>
<small style="color: #666 ;">キャンセル ${cancelled.toLocaleString()} 件</small>
</div>
${adjustment !== 0 ? `
<div class="stat-card" style="background: #e0f7fa ; padding: 15px; border-radius: 8px; text-align: center;">
<h4 style="margin: 0 0 10px 0; color: #00796b ;">調整差額</h4>
<div style="font-size: 2em; font-weight: bold; color: #00796b ;">¥${adjustment.toLocaleString()}</div>
<small style="color: #666 ;">総売上 (毛) ¥${grossSales.toLocaleString()}</small>
</div>
` : ''}
</div>
<div style="margin-top: 30px; padding: 12px; background: #f8f9fa ; border-radius: 6px; font-size: 0.9em; color: #555 ;">
<strong>更新時刻:</strong> ${updatedAt}
</div>
</div>
`;
} else {
tabContent = `
<div class="card">
<h3>売上確認</h3>
<p>売上サマリを表示できませんでした。もう一度読み込んでください。</p>
<button class="btn btn-primary" onclick="fetchSalesSummary(true)">再読込</button>
</div>
`;
}
} else if (state.settingsTab === 'chinchiro') {
tabContent = `
<div class="card">
<div class="settings-header">
<div>
<h3>🎲 ちんちろ設定</h3>
<p class="settings-subtitle">セット商品の倍率と丸め方法を調整します</p>
</div>
</div>
<div class="settings-panel">
<section class="settings-section">
<h4>利用可否</h4>
<label class="settings-toggle">
<input type="checkbox" ${state.data.settings.chinchiro.enabled ? 'checked' : ''} id="chinchiro-enabled">
<span>ちんちろ機能を有効にする</span>
</label>
<p class="settings-note">有効にすると、注文画面でセット商品の価格倍率を選択できます。</p>
</section>
<section class="settings-section">
<h4>倍率設定</h4>
<p class="settings-note">カンマ区切りで倍率を指定(例: 0,0.5,1,2,3)</p>
<div class="settings-field">
<label for="chinchiro-multipliers">倍率リスト</label>
<input type="text" value="${state.data.settings.chinchiro.multipliers.join(',')}" id="chinchiro-multipliers">
</div>
<div class="settings-field">
<strong>倍率の意味</strong>
<ul class="settings-list">
<li><code>0</code> = 無料(ピンゾロ)</li>
<li><code>0.5</code> = 半額</li>
<li><code>1</code> = 通常価格(変更なし)</li>
<li><code>2</code> = 2倍</li>
<li><code>3</code> = 3倍</li>
</ul>
</div>
</section>
<section class="settings-section">
<h4>丸め方式</h4>
<p class="settings-note">調整額に小数が出た場合の処理方法</p>
<div class="settings-field">
<label for="chinchiro-rounding">丸め方法</label>
<select id="chinchiro-rounding">
<option value="round" ${state.data.settings.chinchiro.rounding === 'round' ? 'selected' : ''}>四捨五入</option>
<option value="floor" ${state.data.settings.chinchiro.rounding === 'floor' ? 'selected' : ''}>切り捨て(お客様有利)</option>
<option value="ceil" ${state.data.settings.chinchiro.rounding === 'ceil' ? 'selected' : ''}>切り上げ(店舗有利)</option>
</select>
</div>
</section>
</div>
<div class="settings-actions">
<button class="btn btn-primary btn-large" onclick="saveChinchoiroSettings()">💾 設定を保存</button>
</div>
</div>
`;
} else if (state.settingsTab === 'qrprint') {
tabContent = `
<div class="card">
<div class="settings-header">
<div>
<h3>🖨️ プリント設定</h3>
<p class="settings-subtitle">レシート印刷時のQRコード設定</p>
</div>
</div>
<div class="settings-panel">
<section class="settings-section">
<h4>QRコード印刷</h4>
<label class="settings-toggle">
<input type="checkbox" ${state.data.settings.qrPrint.enabled ? 'checked' : ''} id="qrprint-enabled">
<span>QRコード印刷を有効にする</span>
</label>
<p class="settings-note">有効にすると、レシートの最後にQRコードが印刷されます。</p>
</section>
<section class="settings-section">
<h4>QRコード内容</h4>
<p class="settings-note">URL、メッセージ等を入力してください</p>
<div class="settings-field">
<label for="qrprint-content">印刷する内容</label>
<textarea id="qrprint-content" placeholder="例: https://example.com またはメッセージテキスト">${state.data.settings.qrPrint.content || ''}</textarea>
</div>
<div class="settings-field">
<strong>使用例</strong>
<ul class="settings-list">
<li>店舗ウェブサイトURL</li>
<li>アンケートフォーム</li>
<li>SNSアカウント</li>
<li>クーポンコード</li>
<li>お礼メッセージ</li>
</ul>
</div>
</section>
</div>
<div class="settings-actions">
<button class="btn btn-primary btn-large" onclick="saveQrPrintSettings()">💾 設定を保存</button>
</div>
</div>
`;
}
return tabNav + tabContent;
}
function renderCallPage() {
const hasOrders = state.callList.length > 0;
const items = hasOrders ? state.callList.map(item => `
<div class="call-item" data-order="${item.orderNo}">
<div class="call-number">${item.orderNo}</div>
<div class="call-label">番</div>
</div>
`).join('') : '';
return `
<div class="call-screen">
${hasOrders ? `
<div class="call-header">
<h1 onclick="navigateTo('order')" style="cursor: pointer; user-select: none;">お呼び出し</h1>
</div>
<div class="call-grid" id="call-grid">
${items}
</div>
` : `
<div class="call-empty" id="call-empty">
<h1 onclick="navigateTo('order')" style="cursor: pointer; user-select: none;">お待ちください</h1>
<p>現在、呼び出し中の注文はありません</p>
</div>
`}
<div class="call-footer" style="position: fixed; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; background: rgba(0,0,0,0.8);">
<div class="call-time" style="color: white; font-size: 1.2em;"></div>
<button onclick="navigateTo('order')" style="background: rgba(100, 149, 237, 0.3); border: none; color: transparent; width: 60px; height: 40px; border-radius: 5px; cursor: pointer;">nav</button>
</div>
</div>
`;
}
function updateCallScreen() {
const hasOrders = state.callList.length > 0;
const callScreen = document.querySelector('.call-screen');
if (!callScreen) {
return;
}
const callGrid = document.getElementById('call-grid');
const callEmpty = document.getElementById('call-empty');
if (hasOrders) {
const items = state.callList.map(item => `
<div class="call-item" data-order="${item.orderNo}">
<div class="call-number">${item.orderNo}</div>
<div class="call-label">番</div>
</div>
`).join('');
if (callGrid) {
callGrid.innerHTML = items;
} else if (callEmpty) {
callEmpty.outerHTML = `
<div class="call-header">
<h1 onclick="navigateTo('order')" style="cursor: pointer; user-select: none;">お呼び出し</h1>
</div>
<div class="call-grid" id="call-grid">
${items}
</div>
`;
}
} else {
if (callGrid) {
const header = callScreen.querySelector('.call-header');
if (header) header.remove();
callGrid.outerHTML = `
<div class="call-empty" id="call-empty">
<h1 onclick="navigateTo('order')" style="cursor: pointer; user-select: none;">お待ちください</h1>
<p>現在、呼び出し中の注文はありません</p>
</div>
`;
} else if (callEmpty) {
}
}
}
function renderExportPage() {
return `
<div class="card">
<h2>データエクスポート</h2>
<p style="color: #666 ; margin-bottom: 20px;">売上データのエクスポートとバックアップから復旧ができます</p>
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<button class="btn btn-success btn-large" onclick="downloadCsv()" style="flex: 1; min-width: 200px; font-size: 1.2em; padding: 15px 25px;">
CSV エクスポート
</button>
<button class="btn btn-primary btn-large" onclick="downloadSalesSummaryLite()" style="flex: 1; min-width: 200px; font-size: 1.2em; padding: 15px 25px;">
売上サマリ(Lite)出力
</button>
<button class="btn btn-warning btn-large" onclick="restoreLatest()" style="flex: 1; min-width: 200px; font-size: 1.2em; padding: 15px 25px;">
復旧ボタン
</button>
<button class="btn btn-info btn-large" onclick="downloadSnapshotJson()" style="flex: 1; min-width: 200px; font-size: 1.2em; padding: 15px 25px;">
スナップショット確認
</button>
</div>
<div class="memory-monitor" style="margin-top: 30px; padding: 20px; background: #f8f9fa ; border-radius: 8px;">
<h3 style="margin-bottom: 10px;">メモリ使用状況</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="font-size: 1.6em; font-weight: bold; color: #007bff ;">
空きメモリ容量: <span id="memory-free-heap">-- KB</span>
</div>
<div style="color: #666 ;">最小空き: <span id="memory-min-heap">-- KB</span></div>
<div style="color: #666 ;">最大連続割当: <span id="memory-max-alloc">-- KB</span></div>
<div style="color: #999 ; font-size: 0.9em;">最終更新: <span id="memory-last-updated">--:--:--</span></div>
<div id="memory-status-message" style="color: #d32f2f ; font-size: 0.9em;"></div>
</div>
</div>
<div id="api-result" style="margin-top: 20px;"></div>
</div>
`;
}
function setupPageEvents() {
updateCartDisplay();
const completedWidget = document.getElementById('completed-orders-widget');
if (completedWidget && completedWidget.style.display !== 'none') {
loadCompletedOrders();
}
if (state.page === 'call') {
loadCallList();
}
if (state.page === 'pickup') {
document.addEventListener('click', handlePickupButtonClick);
}
if (state.page === 'export') {
startMemoryMonitor();
} else {
stopMemoryMonitor();
}
if (state.settingsTab === 'sales') {
ensureSalesSummary();
}
}
async function updateMemoryStatus() {
const freeElem = document.getElementById('memory-free-heap');
if (!freeElem) {
return;
}
const minElem = document.getElementById('memory-min-heap');
const maxElem = document.getElementById('memory-max-alloc');
const updatedElem = document.getElementById('memory-last-updated');
const messageElem = document.getElementById('memory-status-message');
try {
const response = await fetch('/api/system/memory');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const freeHeap = data.freeHeap ?? data.free_heap ?? 0;
const minHeap = data.minFreeHeap ?? data.min_free_heap ?? null;
const maxAlloc = data.maxAllocHeap ?? data.max_alloc_heap ?? null;
const formatKb = value => `${Math.round(value / 1024)} KB`;
freeElem.textContent = formatKb(freeHeap);
if (minElem && minHeap != null) {
minElem.textContent = formatKb(minHeap);
}
if (maxElem && maxAlloc != null) {
maxElem.textContent = formatKb(maxAlloc);
}
if (updatedElem) {
const now = new Date();
updatedElem.textContent = now.toLocaleTimeString('ja-JP');
state.memory = {
freeHeap,
minHeap,
maxAlloc,
updatedAt: now
};
}
if (messageElem) {
messageElem.textContent = '';
}
} catch (error) {
console.error('メモリ情報取得エラー:', error);
freeElem.textContent = '取得エラー';
if (minElem) minElem.textContent = '-- KB';
if (maxElem) maxElem.textContent = '-- KB';
if (messageElem) {
messageElem.textContent = `メモリ情報の取得に失敗しました: ${error.message}`;
}
}
}
function startMemoryMonitor() {
updateMemoryStatus();
if (memoryMonitorTimer) {
clearInterval(memoryMonitorTimer);
}
memoryMonitorTimer = setInterval(updateMemoryStatus, 20000);
}
function stopMemoryMonitor() {
if (memoryMonitorTimer) {
clearInterval(memoryMonitorTimer);
memoryMonitorTimer = null;
}
}
function handlePickupButtonClick(event) {
const cookedBtn = event.target.closest('.btn-success');
const pickedBtn = event.target.closest('.btn-info');
if (cookedBtn || pickedBtn) {
event.stopPropagation();
const orderCard = event.target.closest('.pickup-card');
if (!orderCard) return;
const orderNo = orderCard.getAttribute('data-order-no');
if (!orderNo) {
console.error('注文番号が見つかりません');
return;
}
if (cookedBtn) {
updateOrderStatus(orderNo, 'DONE');
} else if (pickedBtn) {
updateOrderStatus(orderNo, 'READY');
}
}
}
async function loadCallList() {
try {
console.log('呼び出しリスト取得開始...');
const response = await fetch('/api/call-list');
const data = await response.json();
state.callList = data.callList || [];
console.log('呼び出しリスト取得完了:', state.callList.length, '件', state.callList);
if (state.page === 'call') {
updateCallScreen();
}
} catch (error) {
console.error('呼び出しリスト取得エラー:', error);
}
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.error('全画面表示エラー:', err);
});
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}
function showOrderSuccessModal(orderNo) {
const existingModal = document.getElementById('order-success-modal');
if (existingModal) {
existingModal.remove();
}
const modal = document.createElement('div');
modal.id = 'order-success-modal';
modal.className = 'modal-backdrop';
modal.innerHTML = `
<div class="modal-content" style="text-align: center; padding: 30px;">
<div style="font-size: 4em; color: #28a745 ; margin-bottom: 20px;">✅</div>
<h2 style="color: #28a745 ; margin-bottom: 15px;">注文確定</h2>
<p style="font-size: 1.5em; font-weight: bold; margin: 20px 0;">
注文番号: <span style="color: #007bff ; font-size: 2em;">#${orderNo}</span>
</p>
<p style="color: #666 ; margin: 15px 0;">
注文が正常に登録されました。<br>
キッチン画面で確認できます。
</p>
<button class="btn btn-primary btn-large" onclick="closeOrderSuccessModal()" style="margin-top: 20px; font-size: 1.2em; padding: 12px 30px;">
OK
</button>
</div>
`;
document.body.appendChild(modal);
setTimeout(() => {
closeOrderSuccessModal();
}, 3000);
}
function closeOrderSuccessModal() {
const modal = document.getElementById('order-success-modal');
if (modal) {
modal.remove();
}
}
function addMainSingle(sku, priceMode) {
try {
if (state.data.printer.paperOut) {
alert('プリンターの用紙を確認してください');
return;
}
const button = event.target;
if (button.disabled) return;
button.disabled = true;
state.cart.push({
type: 'MAIN_SINGLE',
mainSku: sku,
priceMode: priceMode,
qty: 1
});
button.style.backgroundColor = '#28a745';
const originalText = button.textContent;
button.textContent = '追加完了!';
updateCartDisplay();
setTimeout(() => {
button.disabled = false;
button.style.backgroundColor = '';
button.textContent = originalText;
}, 1000);
} catch (error) {
console.error('カート追加エラー:', error);
alert('注文の追加に失敗しました。');
event.target.disabled = false;
}
}
function showSideSelectModal(mainSku, priceMode) {
const sideItems = state.data.menu.filter(item => item.category === 'SIDE' && item.active);
if (sideItems.length === 0) {
alert('利用可能なサイド商品がありません');
return;
}
const mainItem = state.data.menu.find(item => item.sku === mainSku);
if (!mainItem) return;
const mainPrice = priceMode === 'presale' ?
mainItem.price_normal + mainItem.presale_discount_amount :
mainItem.price_normal;
const modalHtml = `
<div class="modal-backdrop" onclick="closeSideSelectModal()">
<div class="modal-content" onclick="event.stopPropagation()" style="max-width: 600px;">
<div class="card">
<div class="modal-header">
<h3>サイド商品を選択</h3>
<button class="btn-close" onclick="closeSideSelectModal()">×</button>
</div>
<div class="modal-body">
<p><strong>メイン:</strong> ${mainItem.name} (${priceMode === 'presale' ? '前売' : '通常'}: ${mainPrice}円)</p>
<p style="color: #666 ; margin-bottom: 15px;">サイド商品を1つ選択してください:</p>
<div class="side-select-grid" style="display: grid; gap: 10px;">
${sideItems.map(side => `
<button class="btn btn-secondary"
onclick="addSetToCart('${mainSku}', '${priceMode}', '${side.sku}')"
style="width: 100%; padding: 15px; text-align: left;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 1.1em; font-weight: bold;">${side.name}</span>
<span style="color: #28a745 ; font-weight: bold;">+${side.price_as_side}円</span>
</div>
</button>
`).join('')}
</div>
</div>
</div>
</div>
</div>
`;
const modal = document.createElement('div');
modal.id = 'side-select-modal';
modal.innerHTML = modalHtml;
document.body.appendChild(modal);
}
function closeSideSelectModal() {
const modal = document.getElementById('side-select-modal');
if (modal) {
modal.remove();
}
}
function addSetToCart(mainSku, priceMode, sideSku) {
try {
if (state.data.printer.paperOut) {
alert('プリンターの用紙を確認してください');
return;
}
state.cart.push({
type: 'SET',
mainSku: mainSku,
priceMode: priceMode,
sideSkus: [sideSku],
qty: 1
});
updateCartDisplay();
closeSideSelectModal();
const mainItem = state.data.menu.find(item => item.sku === mainSku);
const sideItem = state.data.menu.find(item => item.sku === sideSku);
if (mainItem && sideItem) {
alert(`✅ セットを追加しました\n${mainItem.name} + ${sideItem.name}`);
}
} catch (error) {
console.error('セット追加エラー:', error);
alert('セットの追加に失敗しました。');
}
}
function addToCart(type, sku, priceMode = '') {
try {
if (state.data.printer.paperOut) {
alert('プリンターの用紙を確認してください');
return;
}
const button = event.target;
if (button.disabled) return;
button.disabled = true;
if (type === 'SET') {
console.log('メニューデータ確認:', state.data.menu);
const sideItems = state.data.menu.filter(item => item.category === 'SIDE' && item.active);
console.log('フィルタ後のサイドアイテム:', sideItems);
const selectedSides = sideItems.slice(0, 2).map(item => item.sku);
console.log('選択されたサイドSKU:', selectedSides);
console.log('SETカート追加:', {
type: 'SET',
mainSku: sku,
priceMode: priceMode,
sideSkus: selectedSides,
qty: 1
});
state.cart.push({
type: 'SET',
mainSku: sku,
priceMode: priceMode,
sideSkus: selectedSides,
qty: 1
});
button.style.backgroundColor = '#28a745';
button.textContent = '追加完了!';
} else if (type === 'SIDE_SINGLE') {
state.cart.push({
type: 'SIDE_SINGLE',
sideSku: sku,
qty: 1
});
button.style.backgroundColor = '#28a745';
button.textContent = '追加完了!';
}
updateCartDisplay();
setTimeout(() => {
button.disabled = false;
button.style.backgroundColor = '';
button.textContent = button.textContent.includes('通常') ? '通常で追加' :
button.textContent.includes('前売') ? '前売で追加' : '追加';
}, 1000);
} catch (error) {
console.error('カート追加エラー:', error);
alert('注文の追加に失敗しました。再試行してください。');
const button = event.target;
button.disabled = false;
}
}
function clearCart() {
state.cart = [];
updateCartDisplay();
}
function updateCartDisplay() {
const cartDiv = document.getElementById('cart-items');
if (!cartDiv || !state.data) return;
console.log('=== カート表示デバッグ ===');
console.log('カート内容:', state.cart);
console.log('メニューデータ数:', state.data.menu ? state.data.menu.length : 0);
if (state.cart.length === 0) {
cartDiv.innerHTML = '<p>カートは空です</p>';
return;
}
const chinchoiroEnabled = state.data.settings.chinchiro.enabled;
const multipliers = state.data.settings.chinchiro.multipliers || [1];
let total = 0;
const itemsHtml = state.cart.map((cartItem, idx) => {
let itemTotal = 0;
let basePrice = 0;
let description = '';
let isSet = false;
if (cartItem.type === 'MAIN_SINGLE') {
const mainItem = state.data.menu.find(item => item.sku === cartItem.mainSku);
if (mainItem) {
const mainPrice = cartItem.priceMode === 'presale' ?
mainItem.price_normal + mainItem.presale_discount_amount :
mainItem.price_normal;
itemTotal = mainPrice;
description = `${mainItem.name} (${cartItem.priceMode === 'presale' ? '前売' : '通常'})`;
}
basePrice = itemTotal;
} else if (cartItem.type === 'SET') {
isSet = true;
const mainItem = state.data.menu.find(item => item.sku === cartItem.mainSku);
if (mainItem) {
const mainPrice = cartItem.priceMode === 'presale' ?
mainItem.price_normal + mainItem.presale_discount_amount :
mainItem.price_normal;
itemTotal += mainPrice;
description = ` ${mainItem.name} (${cartItem.priceMode === 'presale' ? '前売' : '通常'})`;
cartItem.sideSkus.forEach(sideSku => {
const sideItem = state.data.menu.find(item => item.sku === sideSku);
if (sideItem) {
itemTotal += sideItem.price_as_side;
description += ` + ${sideItem.name}`;
}
});
}
basePrice = itemTotal;
} else if (cartItem.type === 'SIDE_SINGLE') {
const sideItem = state.data.menu.find(item => item.sku === cartItem.sideSku);
if (sideItem) {
itemTotal = sideItem.price_single;
description = `${sideItem.name} (単品)`;
}
}
const chinchoiroMultiplier =
typeof cartItem.chinchoiroMultiplier === 'number'
? cartItem.chinchoiroMultiplier
: 1.0;
const chinchoiroResult = cartItem.chinchoiroResult ?? 'なし';
if (isSet && chinchoiroEnabled) {
const adjustment = calculateChinchoiroAdjustmentClient(basePrice, chinchoiroMultiplier);
itemTotal = basePrice + adjustment;
const chinchoiroOptions = multipliers.map(m => {
const label = getChinchoiroLabel(m);
const selected = Math.abs(m - chinchoiroMultiplier) < 0.01 ? 'selected' : '';
return `<option value="${m}" ${selected}>${label}</option>`;
}).join('');
const chinchoiroSelect = `
<div style="margin-top: 10px; padding: 10px; background: #fff3cd ; border-radius: 5px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">🎲 ちんちろ結果:</label>
<select class="form-control" onchange="applyChinchoiro(${idx}, parseFloat(this.value))" style="padding: 5px;">
${chinchoiroOptions}
</select>
${adjustment !== 0 ? `<small style="display: block; margin-top: 5px; color: ${adjustment > 0 ? '#d9534f' : '#5cb85c'};">
調整額: ${adjustment > 0 ? '+' : ''}${adjustment}円
</small>` : ''}
</div>
`;
const lineTotal = itemTotal * cartItem.qty;
total += lineTotal;
return `
<div class="cart-item-card" style="border: 2px solid #ffc107 ; padding: 12px; margin: 8px 0; border-radius: 8px; background: #fffef5 ;">
<p style="margin: 0 0 8px 0; font-weight: bold;">${description}</p>
<p style="margin: 0 0 8px 0;">基本価格: ${basePrice}円 × ${cartItem.qty}個</p>
${chinchoiroSelect}
<p style="margin: 8px 0 0 0; font-size: 1.1em; font-weight: bold; color: #333 ;">
小計: ${lineTotal}円
</p>
<button class="btn btn-secondary btn-sm" onclick="removeFromCart(${idx})" style="margin-top: 8px;">削除</button>
</div>
`;
} else {
const lineTotal = itemTotal * cartItem.qty;
total += lineTotal;
return `
<div style="border: 1px solid #ddd ; padding: 10px; margin: 5px 0; border-radius: 5px;">
<p style="margin: 0 0 8px 0;">${description}</p>
<p style="margin: 0;">数量: ${cartItem.qty} × ${itemTotal}円 = ${lineTotal}円</p>
<button class="btn btn-secondary btn-sm" onclick="removeFromCart(${idx})" style="margin-top: 5px;">削除</button>
</div>
`;
}
}).join('');
cartDiv.innerHTML = itemsHtml + `<p style="margin-top: 15px; font-size: 1.3em;"><strong>合計: ${total}円</strong></p>`;
updateConfirmOrderButton();
}
function getChinchoiroLabel(multiplier) {
if (multiplier === 0) return 'ピンゾロ(無料)';
if (multiplier === 0.5) return '半額';
if (multiplier === 1.0) return 'なし(通常)';
if (multiplier === 2.0) return '2倍';
if (multiplier === 3.0) return '3倍';
return `${multiplier}倍`;
}
function calculateChinchoiroAdjustmentClient(basePrice, multiplier) {
const rounding = state.data.settings.chinchiro.rounding || 'round';
const rawAdjustment = basePrice * (multiplier - 1.0);
if (rounding === 'floor') {
return Math.floor(rawAdjustment);
} else if (rounding === 'ceil') {
return Math.ceil(rawAdjustment);
} else {
return Math.round(rawAdjustment);
}
}
function applyChinchoiro(cartIndex, multiplier) {
if (cartIndex < 0 || cartIndex >= state.cart.length) return;
const cartItem = state.cart[cartIndex];
cartItem.chinchoiroMultiplier = multiplier;
cartItem.chinchoiroResult = getChinchoiroLabel(multiplier);
updateCartDisplay();
}
function removeFromCart(index) {
state.cart.splice(index, 1);
updateCartDisplay();
}
function updateConfirmOrderButton() {
const btn = document.querySelector('#confirm-order-btn, [data-action="confirm-order"]');
if (!btn) return;
const shouldDisable = (state.cart.length === 0) || (state?.data?.printer?.paperOut);
btn.disabled = shouldDisable;
}
function safeNum(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
async function handleConfirmOrder(event) {
event.preventDefault();
event.stopPropagation();
const button = event.target;
if (button.dataset.loading === "1" || button.disabled) {
console.log('注文送信中またはボタン無効 - スキップ');
return;
}
console.log('handleConfirmOrder: 注文確定処理開始');
button.dataset.loading = "1";
try {
await submitOrder();
} catch (error) {
console.error('注文確定エラー:', error);
} finally {
delete button.dataset.loading;
}
}
async function submitOrder() {
console.log('=== submitOrder 呼び出し開始 ===');
console.log('カート内容:', state.cart);
console.log('カートサイズ:', state.cart.length);
if (state.cart.length === 0) {
alert('カートが空です');
return;
}
const submitBtn = document.querySelector('[data-action="confirm-order"]');
console.log('ボタン状態:', submitBtn ? `disabled=${submitBtn.disabled}` : 'ボタンが見つからない');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = '⏳ 注文処理中...';
console.log('ボタンをdisabledに設定');
}
const safeCart = state.cart.map(item => {
const safeItem = { ...item };
if ('qty' in safeItem) safeItem.qty = Math.max(1, safeNum(safeItem.qty));
if ('unitPriceApplied' in safeItem) safeItem.unitPriceApplied = safeNum(safeItem.unitPriceApplied);
if ('unitPrice' in safeItem) safeItem.unitPrice = safeNum(safeItem.unitPrice);
if ('discountValue' in safeItem) safeItem.discountValue = safeNum(safeItem.discountValue);
return safeItem;
});
console.log('NaN耐性処理後のカート:', safeCart);
const maxRetries = 3;
let retryCount = 0;
while (retryCount < maxRetries) {
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lines: safeCart }),
timeout: 10000
});
if (response.ok) {
const result = await response.json();
console.log('=== 注文送信デバッグ ===');
console.log('送信データ:', { lines: state.cart });
console.log('サーバー応答:', result);
clearCart();
await loadStateData();
updateConfirmOrderButton();
showOrderSuccessModal(result.orderNo);
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.style.backgroundColor = '';
submitBtn.textContent = '📝 注文確定';
}
return;
} else {
const errorData = await response.text();
let errorMsg;
try {
const errorJson = JSON.parse(errorData);
errorMsg = errorJson.error || '不明なエラー';
} catch {
errorMsg = `HTTP ${response.status}: ${errorData}`;
}
throw new Error(errorMsg);
}
} catch (error) {
retryCount++;
console.error(`注文送信失敗 (試行${retryCount}/${maxRetries}):`, error);
if (retryCount < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000));
submitBtn.textContent = `再試行中... (${retryCount + 1}/${maxRetries})`;
} else {
alert(`注文の送信に失敗しました: ${error.message}\n\nカートの内容は保持されています。再度お試しください。`);
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.style.backgroundColor = '#dc3545';
submitBtn.textContent = '📝 注文確定(再試行)';
setTimeout(() => {
submitBtn.style.backgroundColor = '';
submitBtn.textContent = '📝 注文確定';
}, 5000);
}
}
}
}
}
async function cancelOrder(orderNo) {
console.log('[cancelOrder] 開始: 注文番号=', orderNo, 'タイプ=', typeof orderNo);
const resolved = getOrderFromState(orderNo);
if (!resolved) {
console.error('[cancelOrder] 注文が見つかりません:', orderNo);
alert(`❌ 注文 #${orderNo} が見つかりません`);
return;
}
const { order, source } = resolved;
console.log('[cancelOrder] 注文データ検索結果:', { ...order, __source: source });
if (order.status === 'CANCELLED') {
alert(`❌ 注文 #${orderNo} は既にキャンセル済みです`);
return;
}
const reason = prompt('キャンセル理由を入力してください(任意):') || '';
try {
const payload = { orderNo, reason };
console.log('[cancelOrder] 送信データ(JSON):', payload);
const response = await fetch('/api/orders/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log('[cancelOrder] レスポンス:', response.status, response.statusText);
if (response.ok) {
const data = await response.json();
console.log('[cancelOrder] キャンセル成功:', data);
alert(`✅ 注文 #${orderNo} をキャンセルしました`);
await loadStateData();
ensureArchivedOrders(true);
} else {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
const errorText = await response.text();
errorMessage = errorText || errorMessage;
}
console.error('[cancelOrder] キャンセル失敗:', errorMessage);
alert(`❌ キャンセルに失敗しました: ${errorMessage}`);
}
} catch (error) {
console.error('[cancelOrder] 通信エラー:', error);
alert(`❌ 通信エラー: ${error.message}\n\nネットワーク接続を確認してください。`);
}
}
async function completeOrder(orderNo) {
try {
const response = await fetch('/api/orders/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderNo: orderNo, status: 'DONE' })
});
if (response.ok) {
alert(`注文 # ${orderNo} を完了しました`);
loadStateData();
} else {
alert('完了処理に失敗しました');
}
} catch (error) {
alert(`通信エラー: ${error.message}`);
}
}
function switchSettingsTab(tab) {
state.settingsTab = tab;
if (tab === 'sales') {
ensureSalesSummary();
}
render();
}
async function saveMainProducts() {
const items = [];
const mainItems = state.data.menu.filter(item => item.category === 'MAIN');
mainItems.forEach((item, idx) => {
items.push({
id: document.getElementById(`main-id-${idx}`).value,
name: document.getElementById(`main-name-${idx}`).value,
nameRomaji: document.getElementById(`main-name-romaji-${idx}`).value,
price_normal: parseInt(document.getElementById(`main-normal-${idx}`).value) || 0,
presale_discount_amount: parseInt(document.getElementById(`main-discount-${idx}`).value) || 0,
active: document.getElementById(`main-active-${idx}`).checked
});
});
const newName = document.getElementById('main-name-new').value;
if (newName) {
items.push({
name: newName,
nameRomaji: document.getElementById('main-name-romaji-new').value,
price_normal: parseInt(document.getElementById('main-normal-new').value) || 0,
presale_discount_amount: parseInt(document.getElementById('main-discount-new').value) || 0,
active: document.getElementById('main-active-new').checked
});
}
try {
const response = await fetch('/api/products/main', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items })
});
if (response.ok) {
alert('メイン商品を保存しました');
await loadMenu({ force: true });
await loadStateData({ forceFull: true });
} else {
alert('保存に失敗しました');
}
} catch (error) {
alert(`通信エラー: ${error.message}`);
}
}
async function saveSystemSettings() {
const settings = {
presaleEnabled: document.getElementById('presale-enabled').checked,
store: {
name: document.getElementById('store-name').value,
nameRomaji: document.getElementById('store-name-romaji').value,
registerId: document.getElementById('register-id').value
},
numbering: {
min: parseInt(document.getElementById('numbering-min').value) || 1,
max: parseInt(document.getElementById('numbering-max').value) || 9999
}
};
try {
const response = await fetch('/api/settings/system', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (response.ok) {
alert('システム設定を保存しました');
loadStateData();
} else {
alert('保存に失敗しました');
}
} catch (error) {
alert(`通信エラー: ${error.message}`);
}
}
async function saveSideProducts() {
const items = [];
const sideItems = state.data.menu.filter(item => item.category === 'SIDE');
sideItems.forEach((item, idx) => {
items.push({
id: document.getElementById(`side-id-${idx}`).value,
name: document.getElementById(`side-name-${idx}`).value,
nameRomaji: document.getElementById(`side-name-romaji-${idx}`).value,
price_single: parseInt(document.getElementById(`side-single-${idx}`).value) || 0,
price_as_side: parseInt(document.getElementById(`side-set-${idx}`).value) || 0,
active: document.getElementById(`side-active-${idx}`).checked
});
});
const newName = document.getElementById('side-name-new').value;
if (newName) {
items.push({
name: newName,
nameRomaji: document.getElementById('side-name-romaji-new').value,
price_single: parseInt(document.getElementById('side-single-new').value) || 0,
price_as_side: parseInt(document.getElementById('side-set-new').value) || 0,
active: document.getElementById('side-active-new').checked
});
}
try {
const response = await fetch('/api/products/side', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items })
});
if (response.ok) {
alert('サイド商品を保存しました');
await loadMenu({ force: true });
await loadStateData({ forceFull: true });
} else {
alert('保存に失敗しました');
}
} catch (error) {
alert(`通信エラー: ${error.message}`);
}
}
async function saveChinchoiroSettings() {
const enabled = document.getElementById('chinchiro-enabled').checked;
const multipliers = document.getElementById('chinchiro-multipliers').value
.split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
const rounding = document.getElementById('chinchiro-rounding').value;
try {
const response = await fetch('/api/settings/chinchiro', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, multipliers, rounding })
});
if (response.ok) {
alert('ちんちろ設定を保存しました');
loadStateData();
} else {
alert('保存に失敗しました');
}
} catch (error) {
alert(`通信エラー: ${error.message}`);
}
}
async function saveQrPrintSettings() {
const enabled = document.getElementById('qrprint-enabled').checked;
const content = document.getElementById('qrprint-content').value.trim();
try {
const response = await fetch('/api/settings/qrprint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, content })
});
if (response.ok) {
alert('QRプリント設定を保存しました');
loadStateData();
} else {
alert('保存に失敗しました');
}
} catch (error) {
alert(`通信エラー: ${error.message}`);
}
}
function updateMainItem(sku, field, value) {
if (!state.data) return;
const item = state.data.menu.find(m => m.sku === sku && m.category === 'MAIN');
if (item) {
item[field] = value;
debouncedSaveMenuItem(item);
}
}
function updateSideItem(sku, field, value) {
if (!state.data) return;
const item = state.data.menu.find(m => m.sku === sku && m.category === 'SIDE');
if (item) {
item[field] = value;
debouncedSaveMenuItem(item);
}
}
function toggleMainItemActive(sku, active) {
if (!state.data) return;
const item = state.data.menu.find(m => m.sku === sku && m.category === 'MAIN');
if (item) {
item.active = active;
saveMenuItemImmediate(item);
}
}
function toggleSideItemActive(sku, active) {
if (!state.data) return;
const item = state.data.menu.find(m => m.sku === sku && m.category === 'SIDE');
if (item) {
item.active = active;
saveMenuItemImmediate(item);
}
}
function addNewMainItem() {
const name = prompt('新しいメイン商品名を入力してください:');
if (!name) return;
const nameRomaji = prompt('商品名(ローマ字)を入力してください:', name);
const priceNormal = parseInt(prompt('通常価格を入力してください:', '500') || '500');
const presaleDiscount = parseInt(prompt('前売割引額を入力してください(マイナス値):', '-100') || '-100');
const newItem = {
name: name,
nameRomaji: nameRomaji || name,
category: 'MAIN',
active: true,
price_normal: priceNormal,
presale_discount_amount: presaleDiscount,
price_presale: 0,
price_single: 0,
price_as_side: 0
};
saveNewMenuItem(newItem);
}
function addNewSideItem() {
const name = prompt('新しいサイド商品名を入力してください:');
if (!name) return;
const nameRomaji = prompt('商品名(ローマ字)を入力してください:', name);
const priceSingle = parseInt(prompt('単品価格を入力してください:', '200') || '200');
const priceAsside = parseInt(prompt('セット時価格を入力してください:', '100') || '100');
const newItem = {
name: name,
nameRomaji: nameRomaji || name,
category: 'SIDE',
active: true,
price_normal: 0,
presale_discount_amount: 0,
price_presale: 0,
price_single: priceSingle,
price_as_side: priceAsside
};
saveNewMenuItem(newItem);
}
async function saveNewMenuItem(item) {
try {
const endpoint = item.category === 'MAIN' ? '/api/products/main' : '/api/products/side';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: [item] })
});
if (response.ok) {
alert('商品を追加しました');
await loadStateData();
render();
} else {
alert('商品の追加に失敗しました');
}
} catch (error) {
console.error('商品追加エラー:', error);
alert('通信エラーが発生しました');
}
}
let saveMenuTimer = null;
function debouncedSaveMenu() {
if (saveMenuTimer) clearTimeout(saveMenuTimer);
saveMenuTimer = setTimeout(() => {
saveMenuImmediate();
}, 1000);
}
async function saveMenuItemImmediate(item) {
if (!item || !item.sku) {
console.error('SKUが見つかりません:', item);
return;
}
try {
const endpoint = item.category === 'MAIN' ? '/api/products/main' : '/api/products/side';
const payload = {
items: [{
id: item.sku,
name: item.name,
nameRomaji: item.nameRomaji || item.name,
active: item.active,
...(item.category === 'MAIN' ? {
price_normal: item.price_normal,
presale_discount_amount: item.presale_discount_amount
} : {
price_single: item.price_single,
price_as_side: item.price_as_side
})
}]
};
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
console.log(`✅ 商品を更新しました: ${item.name} (${item.sku})`);
await loadMenu({ force: true });
} else {
console.error('❌ 商品の更新に失敗しました:', await response.text());
}
} catch (error) {
console.error('❌ 商品更新エラー:', error);
}
}
let saveMenuItemTimer = null;
function debouncedSaveMenuItem(item) {
if (saveMenuItemTimer) {
clearTimeout(saveMenuItemTimer);
}
saveMenuItemTimer = setTimeout(() => {
saveMenuItemImmediate(item);
}, 1000);
}
async function saveMenuImmediate() {
console.warn('⚠️ saveMenuImmediate()は非推奨です。個別更新を使用してください。');
}
async function downloadCsv() {
try {
window.open('/api/export/csv', '_blank');
setTimeout(() => {
showSessionEndDialog();
}, 2000);
} catch (error) {
console.error('CSVエクスポートエラー:', error);
alert('CSVエクスポートに失敗しました');
}
}
function downloadSalesSummaryLite() {
window.open('/api/export/sales-summary-lite', '_blank');
}
function downloadSnapshotJson() {
window.open('/api/export/snapshot', '_blank');
}
function showSessionEndDialog() {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content session-dialog">
<h2>営業データのエクスポートが完了しました</h2>
<p>今後の営業をどうしますか?</p>
<p class="session-note">📶 「売上確認画面を開く」を押すとKDSのソフトAPが60秒停止します。アップロード後は自動で再開します。</p>
<div class="session-options">
<button class="btn btn-success btn-large" onclick="continueSession()">
🔄 営業を続ける
<small>現在のデータをそのまま継続</small>
</button>
<button class="btn btn-primary btn-large" onclick="openSalesSummaryUploader()">
📤 売上確認画面を開く
<small>タップするとAPが60秒停止(外部サイト)</small>
</button>
<button class="btn btn-warning btn-large" onclick="confirmEndSession()">
🏁 営業セッション終了
<small>データを初期化して新しいセッションを開始</small>
</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target === modal) {
continueSession();
}
});
}
function continueSession() {
closeSessionDialog();
alert('営業を継続します。現在のデータが保持されます。');
}
function confirmEndSession() {
const confirmed = confirm(
'⚠️ 営業セッション終了の確認\n\n' +
'本当に営業セッションを終了しますか?\n' +
'• 全ての注文データが削除されます\n' +
'• メニュー設定は保持されます\n' +
'• 新しいセッション登録画面に移動します\n\n' +
'この操作は取り消せません。'
);
if (confirmed) {
endSession();
}
}
async function endSession() {
try {
const response = await fetch('/api/session/end', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const modal = document.querySelector('.modal-overlay');
if (modal) {
document.body.removeChild(modal);
}
alert('🎉 営業セッションが終了しました。\n新しいセッションを開始してください。');
await loadStateData();
state.page = 'order';
render();
} else {
const error = await response.text();
alert(`セッション終了に失敗しました: ${error}`);
}
} catch (error) {
console.error('セッション終了エラー:', error);
alert(`通信エラー: ${error.message}`);
}
}
async function restoreLatest() {
if (!confirm('最新のスナップショット + WAL ログから復元しますか?\n\n※電源断前の状態に戻ります')) return;
const resultDiv = document.getElementById('api-result');
if (resultDiv) {
resultDiv.innerHTML = '<div class="card"><p>⏳ 復元処理中...</p></div>';
}
try {
const response = await fetch('/api/recover', { method: 'POST' });
const result = await response.json();
if (result.ok) {
console.log('復元成功:', result);
if (resultDiv) {
resultDiv.innerHTML = `
<div class="card" style="border-left-color: #28a745 ;">
<h3>✅ 復旧成功</h3>
<p><strong>適用時刻:</strong> ${result.lastTs || '-'}</p>
<p style="color: #666 ; margin-top: 10px;">スナップショット + WAL ログから状態を復元しました。<br>画面を更新して最新状態を確認してください。</p>
</div>
`;
}
await loadStateData();
alert(`✅ 復元完了\n\n適用時刻: ${result.lastTs}`);
} else {
console.error('復元失敗:', result.error);
if (resultDiv) {
resultDiv.innerHTML = `
<div class="card" style="border-left-color: #dc3545 ;">
<h3>❌ 復旧失敗</h3>
<p><strong>エラー:</strong> ${result.error || 'unknown error'}</p>
<p style="color: #666 ; margin-top: 10px;">復元処理中にエラーが発生しました。</p>
</div>
`;
}
alert(`❌ 復元失敗: ${result.error}`);
}
} catch (error) {
console.error('通信エラー:', error);
if (resultDiv) {
resultDiv.innerHTML = `
<div class="card" style="border-left-color: #dc3545 ;">
<h3>❌ 復旧失敗</h3>
<p><strong>エラー:</strong> ${error.message}</p>
<p style="color: #666 ; margin-top: 10px;">通信エラーが発生しました。ネットワーク接続を確認してください。</p>
</div>
`;
}
alert(`通信エラー: ${error.message}`);
}
}
function showOrderDetail(orderNo) {
if (!state.data) return;
const resolved = getOrderFromState(orderNo);
if (!resolved) return;
const { order, source } = resolved;
const totalAmount = order.items.reduce((sum, item) => {
const unitPrice = item.unitPriceApplied || item.unitPrice || 0;
const qty = item.qty || 1;
const discount = item.discountValue || 0;
return sum + (unitPrice * qty - discount);
}, 0);
const itemsList = order.items.map(item => {
const unitPrice = item.unitPriceApplied || item.unitPrice || 0;
const qty = item.qty || 1;
const discount = item.discountValue || 0;
const lineTotal = unitPrice * qty - discount;
return `
<div class="order-item-row">
<span>${item.name} x${qty}</span>
<span>¥${lineTotal}</span>
</div>
`;
}).join('');
const statusActions = getStatusActions(order);
const modalHtml = `
<div class="modal-backdrop" onclick="closeModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="card">
<div class="modal-header">
<h3>#${order.orderNo}</h3>
<button class="btn-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<p><strong>状態:</strong> ${getStatusLabel(order.status)}</p>
<p><strong>注文時刻:</strong> ${order.ts && order.ts > 946684800 ? new Date(order.ts * 1000).toLocaleString() : '時刻不明'}</p>
<p><strong>ソース:</strong> ${source === 'archived' ? 'アーカイブ' : 'リアルタイム'}</p>
<div class="order-items">
<h4>注文内容</h4>
${itemsList}
<div class="order-total">
<strong>合計: ¥${totalAmount}</strong>
</div>
</div>
${statusActions}
</div>
</div>
</div>
</div>
`;
const modal = document.createElement('div');
modal.innerHTML = modalHtml;
document.body.appendChild(modal);
}
function closeModal() {
const modal = document.querySelector('.modal-backdrop');
if (modal) {
modal.remove();
}
}
function getStatusActions(order) {
const actions = [];
if (order.status === 'COOKING' && !order.cooked) {
actions.push(`
<button class="btn btn-warning" onclick="updateOrderStatus('${order.orderNo}', 'DONE')"
style="width: 100%; margin-top: 5px; font-size: 1.6em; padding: 20px 30px;">
📌 調理完了
</button>
`);
}
if (order.status === 'DONE' && !order.picked_up) {
actions.push(`
<button class="btn btn-primary" onclick="updateOrderStatus('${order.orderNo}', 'READY')"
style="width: 100%; margin-top: 5px; font-size: 1.6em; padding: 20px 30px;">
📌 品出し完了
</button>
`);
}
return actions.length > 0 ? `
<div class="modal-actions">
<div style="background: #f8f9fa ; padding: 10px; border-radius: 5px; margin-bottom: 15px; text-align: center;">
<strong style="color: #007acc ;">👇 状態を変更する</strong>
</div>
${actions.join('')}
</div>
` : '';
}
function getStatusLabel(status) {
const labels = {
'COOKING': '調理中',
'DONE': '調理完了',
'READY': '品出し完了',
'DELIVERED': '提供済み',
'CANCELLED': 'キャンセル'
};
return labels[status] || status;
}
async function updateOrderStatus(orderNo, newStatus) {
console.log(`注文状態更新: ${orderNo} → ${newStatus}`);
try {
const response = await fetch(`/api/orders/${orderNo}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (response.ok) {
console.log(`✅ 注文 ${orderNo} を ${newStatus} に更新`);
closeModal();
await loadStateData();
await loadCallList();
} else {
const errorText = await response.text();
console.error(`❌ API失敗: ${errorText}`);
alert(`状態更新に失敗しました\nStatus: ${response.status}`);
}
} catch (error) {
console.error('状態更新エラー:', error);
alert(`状態更新に失敗しました\n${error.message}`);
}
}
function completeOrder(orderNo) {
console.warn('⚠️ completeOrder は非推奨です。updateOrderStatus を使用してください');
updateOrderStatus(orderNo, 'DONE');
}
async function resetSystem() {
if (!confirm('⚠️ 警告: システムを完全初期化します。\n\n• 全ての注文データが削除されます\n• 注文番号カウンタがリセットされます\n• 不揮発性メモリがクリアされます\n\n本当に実行しますか?')) {
return;
}
if (!confirm('🔴 最終確認: この操作は取り消せません。\n本当にシステムを初期化しますか?')) {
return;
}
try {
const response = await fetch('/api/system/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const result = await response.json();
alert('✅ システム初期化完了\n\n' + result.message);
await loadStateData();
state.cart = [];
updateCartDisplay();
} else {
const errorData = await response.text();
alert('❌ 初期化に失敗しました: ' + errorData);
}
} catch (error) {
console.error('システム初期化エラー:', error);
alert('❌ 初期化に失敗しました: ' + error.message);
}
}
function toggleCompletedOrders() {
const widget = document.getElementById('completed-orders-widget');
const button = document.getElementById('toggle-completed-btn');
if (widget.style.display === 'none') {
widget.style.display = 'block';
button.textContent = '📋 注文済み一覧非表示';
loadCompletedOrders();
} else {
widget.style.display = 'none';
button.textContent = '📋 キャンセル・再印刷';
}
}
function loadCompletedOrders() {
if (!state.data) return;
ensureArchivedOrders();
const merged = new Map();
if (state.archived && Array.isArray(state.archived.orders)) {
state.archived.orders.forEach(order => {
if (!order || !order.orderNo) {
return;
}
if (!merged.has(order.orderNo)) {
merged.set(order.orderNo, { ...order, __source: 'archived' });
}
});
}
if (state.data && Array.isArray(state.data.orders)) {
state.data.orders.forEach(order => {
if (!order || !order.orderNo) {
return;
}
merged.set(order.orderNo, { ...order, __source: 'active' });
});
}
const completedOrders = Array.from(merged.values())
.filter(order => ['COOKING', 'DONE', 'READY', 'DELIVERED', 'CANCELLED'].includes(order.status))
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
.slice(0, 20);
const listDiv = document.getElementById('completed-orders-list');
if (completedOrders.length === 0) {
listDiv.innerHTML = '<p>注文履歴がありません</p>';
return;
}
listDiv.innerHTML = completedOrders.map(order => {
const statusLabel = getStatusLabel(order.status);
const statusColor = getStatusColor(order.status);
const timeStr = order.ts && order.ts > 946684800 ?
new Date(order.ts * 1000).toLocaleString() : '時刻不明';
const sourceBadge = order.__source === 'archived' ? '<span class="tag" style="background:#6c757d;color:#fff;padding:2px 6px;border-radius:3px;font-size:0.7em;">ARCHIVE</span>' : '';
const items = Array.isArray(order.items) ? order.items : [];
const totalAmount = items.reduce((sum, item) => {
const unitPrice = item.unitPriceApplied || item.unitPrice || 0;
const qty = item.qty || 1;
const discount = item.discountValue || 0;
return sum + (unitPrice * qty - discount);
}, 0);
const allowReprint = order.status !== 'CANCELLED';
const allowCancel = ['COOKING', 'DONE', 'READY', 'DELIVERED'].includes(order.status);
return `
<div class="completed-order-item" style="border: 1px solid #ddd ; margin: 10px 0; padding: 15px; border-radius: 5px;">
<div class="order-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h4 style="margin: 0; color: #333 ;">注文 #${order.orderNo} ${sourceBadge}</h4>
<span class="status-badge" style="background-color: ${statusColor}; color: white; padding: 4px 8px; border-radius: 3px; font-size: 0.8em;">
${statusLabel}
</span>
</div>
<div class="order-info" style="font-size: 0.9em; color: #666 ; margin-bottom: 10px;">
<div>注文時刻: ${timeStr}</div>
<div>合計金額: ${totalAmount}円</div>
</div>
<div class="order-items" style="margin-bottom: 15px;">
${items.slice(0, 3).map(item =>
`<span style="background: #f8f9fa ; padding: 2px 6px; margin: 2px; border-radius: 3px; font-size: 0.8em; display: inline-block;">
${item.name} x${item.qty}
</span>`
).join('')}
${items.length > 3 ? '<span style="color: #666 ;">...</span>' : ''}
</div>
<div class="order-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
<button class="btn btn-sm btn-info" onclick="showOrderDetail('${order.orderNo}')" style="font-size: 0.8em;">
📄 詳細
</button>
${allowReprint ? `
<button class="btn btn-sm btn-secondary" onclick="reprintReceipt('${order.orderNo}')" style="font-size: 0.8em;">
🖨️ 再印刷
</button>` : ''}
${allowCancel ? `
<button class="btn btn-sm btn-warning" onclick="cancelOrder('${order.orderNo}')" style="font-size: 0.8em;">
❌ キャンセル
</button>` : ''}
</div>
</div>
`;
}).join('');
}
function getStatusColor(status) {
const colors = {
'COOKING': '#ffc107',
'DONE': '#28a745',
'READY': '#17a2b8',
'DELIVERED': '#6c757d',
'CANCELLED': '#dc3545'
};
return colors[status] || '#6c757d';
}
function refreshSalesStats() {
if (state.settingsTab === 'sales') {
fetchSalesSummary(true);
}
}
async function reprintReceipt(orderNo) {
console.log('[reprintReceipt] 開始: 注文番号=', orderNo, 'タイプ=', typeof orderNo);
if (!confirm(`注文 #${orderNo} のレシートを再印刷しますか?`)) {
console.log('[reprintReceipt] ユーザーがキャンセルしました');
return;
}
const resolved = getOrderFromState(orderNo);
if (!resolved) {
console.error('[reprintReceipt] 注文が見つかりません:', orderNo);
alert(`❌ 注文 #${orderNo} が見つかりません`);
return;
}
const { order, source } = resolved;
console.log('[reprintReceipt] 注文データ検索結果:', { ...order, __source: source });
if (order.status === 'CANCELLED') {
alert(`❌ キャンセル済みの注文は再印刷できません\n注文番号: ${orderNo}`);
return;
}
try {
const requestBody = { orderNo: orderNo };
console.log('[reprintReceipt] 送信データ:', requestBody);
const response = await fetch('/api/orders/reprint', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
console.log('[reprintReceipt] レスポンス:', response.status, response.statusText);
if (response.ok) {
const result = await response.json();
console.log('[reprintReceipt] 成功:', result);
alert(`✅ レシート再印刷を実行しました\n注文番号: ${orderNo}\n\n${result.message || 'プリンタキューに追加しました'}`);
await loadStateData();
} else {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
const errorText = await response.text();
errorMessage = errorText || errorMessage;
}
console.error('[reprintReceipt] エラーレスポンス:', errorMessage);
alert(`❌ 再印刷に失敗しました: ${errorMessage}`);
}
} catch (error) {
console.error('[reprintReceipt] 通信エラー:', error);
alert(`❌ 通信エラー: ${error.message}\n\nネットワーク接続を確認してください。`);
}
}
function closeSessionDialog() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
async function openSalesSummaryUploader() {
const confirmed = confirm(
'📤 売上確認ツールを開くと、KDSのソフトAPが60秒間停止します。\n' +
'この間、タブレットは一時的に切断されます。続行しますか?'
);
if (!confirmed) {
return;
}
const uploadUrl = 'https://kds-checker.vercel.app/upload';
const popup = window.open('about:blank', '_blank');
try {
const response = await fetch('/api/network/ap-cycle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resumeAfter: 60 })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
console.log('[openSalesSummaryUploader] AP suspend requested:', result);
} catch (error) {
console.error('[openSalesSummaryUploader] Failed to suspend AP:', error);
if (popup && !popup.closed) {
popup.close();
}
alert('⚠️ ソフトAPの一時停止に失敗しました。ネットワークを確認してから再度お試しください。');
return;
}
closeSessionDialog();
if (popup && !popup.closed) {
popup.location.href = uploadUrl;
} else {
window.open(uploadUrl, '_blank');
}
alert('売上確認ツールを新しいタブで開きました。タブレットは60秒後に自動でKDS Wi-Fiへ再接続します。');
}