Initial commit
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.git
|
||||
.gitignore
|
||||
**/*.log
|
||||
|
||||
# Local databases and generated data
|
||||
/data
|
||||
/public/uploads
|
||||
|
||||
# Editor/OS
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
15
.env
Normal file
15
.env
Normal file
@@ -0,0 +1,15 @@
|
||||
# Kitchen Agent Configuration
|
||||
|
||||
# Google reCAPTCHA Site Key (v2 Invisible)
|
||||
RECAPTCHA_SITE_KEY=6LfoaqIqAAAAABi1P-6T1gQpfXNXMv6aQqH0lwGK
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Cookie Secret (change this in production!)
|
||||
COOKIE_SECRET=0e4cfa9b-ba26-43b7-a6a1-a21585a6495b
|
||||
|
||||
# ThinkLink API URL
|
||||
API_URL=https://api.thinklink.ai
|
||||
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/ # If using JSPM
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Production artifacts
|
||||
build/
|
||||
dist/
|
||||
.next/ # For Next.js projects
|
||||
out/ # For static site generators
|
||||
|
||||
# Editor/IDE specific files
|
||||
.vscode/ # VS Code settings
|
||||
.idea/ # IntelliJ IDEA settings
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
*.pid
|
||||
*.seed
|
||||
*.gz
|
||||
pids/
|
||||
logs/
|
||||
results/
|
||||
data/*
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
FROM node:20-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Install dependencies first to leverage Docker layer caching
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
FROM base AS build
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install production deps (will compile native modules like better-sqlite3/serialport if needed)
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Runtime OS packages for printing via pdf-to-printer (lp/lpr)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
cups-client \
|
||||
cups-bsd \
|
||||
libcups2 \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy built app with node_modules from the build stage
|
||||
COPY --from=build /app /app
|
||||
|
||||
# Create volumes for persistent data and user uploads
|
||||
VOLUME ["/app/data", "/app/public/uploads"]
|
||||
|
||||
# Ensure non-root runtime; change ownership so the node user can write to volumes
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --start-period=30s --timeout=5s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
||||
|
||||
94
api-client.js
Normal file
94
api-client.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
class APIClient {
|
||||
constructor(baseUrl = process.env.API_URL || 'https://api.thinklink.ai') {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`API request failed: ${endpoint}`, error.message);
|
||||
return {
|
||||
error: true,
|
||||
message: `Network error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async login(email, password, recaptchaToken) {
|
||||
return this.request('/user/login', {
|
||||
body: {
|
||||
login: email,
|
||||
password: password,
|
||||
'g-recaptcha-response': recaptchaToken
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getBots(token) {
|
||||
return this.request('/bot/list', {
|
||||
body: {
|
||||
token: token
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getOrders(token, botId, afterId = 0, options = {}) {
|
||||
const body = {
|
||||
token: token,
|
||||
botId: parseInt(botId, 10),
|
||||
afterId: afterId || 0,
|
||||
limit: options.limit || 50,
|
||||
includeCanceled: options.includeCanceled || false
|
||||
};
|
||||
|
||||
if (options.orderStatus) {
|
||||
body.orderStatus = options.orderStatus;
|
||||
}
|
||||
|
||||
if (options.sinceTs) {
|
||||
body.sinceTs = options.sinceTs;
|
||||
}
|
||||
|
||||
return this.request('/food-order/orders', { body });
|
||||
}
|
||||
|
||||
async modifyOrder(token, botId, orderId, action, cancellationReason = '') {
|
||||
const body = {
|
||||
token: token,
|
||||
botId: parseInt(botId, 10),
|
||||
orderId: parseInt(orderId, 10),
|
||||
action: action
|
||||
};
|
||||
|
||||
if (action === 'cancel' && cancellationReason) {
|
||||
body.cancellationReason = cancellationReason;
|
||||
}
|
||||
|
||||
return this.request('/food-order/modify', { body });
|
||||
}
|
||||
|
||||
isTokenExpired(expirationDate) {
|
||||
if (!expirationDate) return true;
|
||||
const expiry = new Date(expirationDate);
|
||||
const now = new Date();
|
||||
return now >= expiry;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new APIClient();
|
||||
|
||||
159
config.js
Normal file
159
config.js
Normal file
@@ -0,0 +1,159 @@
|
||||
const database = require('./database');
|
||||
const crypto = require('crypto');
|
||||
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.encryptionKey = null;
|
||||
}
|
||||
|
||||
getOrCreateEncryptionKey() {
|
||||
// Lazy initialization - only create key when first needed
|
||||
if (this.encryptionKey) {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
// In production, this should be stored securely (environment variable or secure file)
|
||||
// For now, we'll generate a random key and store it in the database
|
||||
try {
|
||||
let key = database.getConfig('_encryption_key');
|
||||
if (!key) {
|
||||
key = crypto.randomBytes(32).toString('hex');
|
||||
database.setConfig('_encryption_key', key);
|
||||
}
|
||||
this.encryptionKey = key;
|
||||
return key;
|
||||
} catch (error) {
|
||||
// If database not ready, generate temporary key
|
||||
console.warn('Database not ready, using temporary encryption key');
|
||||
this.encryptionKey = crypto.randomBytes(32).toString('hex');
|
||||
return this.encryptionKey;
|
||||
}
|
||||
}
|
||||
|
||||
encrypt(text) {
|
||||
if (!text) return null;
|
||||
try {
|
||||
const key = this.getOrCreateEncryptionKey();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(key, 'hex'),
|
||||
iv
|
||||
);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
decrypt(text) {
|
||||
if (!text) return null;
|
||||
try {
|
||||
const key = this.getOrCreateEncryptionKey();
|
||||
const parts = text.split(':');
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(key, 'hex'),
|
||||
iv
|
||||
);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
try {
|
||||
const value = database.getConfig(key);
|
||||
|
||||
// Decrypt token if it's the auth token
|
||||
if (key === 'authToken' && value) {
|
||||
return this.decrypt(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.warn(`Config.get('${key}') failed:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
try {
|
||||
// Encrypt token if it's the auth token
|
||||
if (key === 'authToken' && value) {
|
||||
value = this.encrypt(value);
|
||||
}
|
||||
|
||||
database.setConfig(key, value);
|
||||
} catch (error) {
|
||||
console.error(`Config.set('${key}') failed:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
getAll() {
|
||||
try {
|
||||
const config = database.getConfig();
|
||||
|
||||
// Decrypt auth token if present
|
||||
if (config.authToken) {
|
||||
config.authToken = this.decrypt(config.authToken);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.warn('Config.getAll() failed:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
setMultiple(configObj) {
|
||||
try {
|
||||
// Encrypt auth token if present
|
||||
if (configObj.authToken) {
|
||||
configObj.authToken = this.encrypt(configObj.authToken);
|
||||
}
|
||||
|
||||
database.setConfigMultiple(configObj);
|
||||
} catch (error) {
|
||||
console.error('Config.setMultiple() failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
try {
|
||||
const token = this.get('authToken');
|
||||
const expiry = this.get('tokenExpiry');
|
||||
|
||||
if (!token) return false;
|
||||
if (!expiry) return false;
|
||||
|
||||
const expiryDate = new Date(expiry);
|
||||
const now = new Date();
|
||||
|
||||
return now < expiryDate;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
clearAuth() {
|
||||
try {
|
||||
this.set('authToken', '');
|
||||
this.set('tokenExpiry', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to clear auth:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ConfigManager();
|
||||
|
||||
721
database.js
Normal file
721
database.js
Normal file
@@ -0,0 +1,721 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
class DatabaseManager {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'kitchen.db');
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
|
||||
this.createTables();
|
||||
console.log('Database initialized successfully');
|
||||
}
|
||||
|
||||
createTables() {
|
||||
// Config table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Orders table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER UNIQUE NOT NULL,
|
||||
bot_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
local_status TEXT,
|
||||
order_data TEXT NOT NULL,
|
||||
customer_data TEXT NOT NULL,
|
||||
total_amount REAL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
printed_at INTEGER,
|
||||
synced_at INTEGER
|
||||
)
|
||||
`);
|
||||
|
||||
// Print queue table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS print_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
print_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL,
|
||||
printed_at INTEGER,
|
||||
FOREIGN KEY (order_id) REFERENCES orders(order_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Printers table - stores individual printer configurations
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS printers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
interface TEXT NOT NULL,
|
||||
printer_type TEXT NOT NULL DEFAULT 'epson',
|
||||
paper_width INTEGER NOT NULL DEFAULT 48,
|
||||
paper_format TEXT NOT NULL DEFAULT '80mm',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
font_size TEXT DEFAULT 'normal',
|
||||
line_style TEXT DEFAULT 'single',
|
||||
qr_code_enabled INTEGER DEFAULT 1,
|
||||
qr_code_size INTEGER DEFAULT 3,
|
||||
qr_code_correction TEXT DEFAULT 'M',
|
||||
qr_code_content_template TEXT DEFAULT 'ORDER-{id}',
|
||||
header_text TEXT DEFAULT 'KITCHEN ORDER',
|
||||
footer_text TEXT DEFAULT 'Thank you!',
|
||||
business_name TEXT,
|
||||
business_address TEXT,
|
||||
business_phone TEXT,
|
||||
business_website TEXT,
|
||||
business_email TEXT,
|
||||
business_contact_size TEXT DEFAULT 'normal',
|
||||
show_customer_info INTEGER DEFAULT 1,
|
||||
show_order_items INTEGER DEFAULT 1,
|
||||
show_prices INTEGER DEFAULT 1,
|
||||
show_timestamps INTEGER DEFAULT 1,
|
||||
logo_path TEXT,
|
||||
logo_max_width_dots INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_local_status ON orders(local_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_created ON orders(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_print_queue_status ON print_queue(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_printers_enabled ON printers(is_enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_printers_default ON printers(is_default);
|
||||
`);
|
||||
|
||||
// Initialize default config values if not exists
|
||||
this.setConfigDefaults();
|
||||
|
||||
// Migrate old printer config to new table if needed
|
||||
this.migrateOldPrinterConfig();
|
||||
}
|
||||
|
||||
setConfigDefaults() {
|
||||
const defaults = {
|
||||
apiUrl: 'https://api.thinklink.ai',
|
||||
pollingInterval: '15000',
|
||||
dashboardRefreshInterval: '10000',
|
||||
printerType: 'epson',
|
||||
printerInterface: 'usb',
|
||||
printerPath: '/dev/usb/lp0',
|
||||
printerWidth: '48',
|
||||
fontSize: 'normal',
|
||||
qrCodeEnabled: 'true',
|
||||
headerText: 'KITCHEN ORDER',
|
||||
footerText: 'Thank you!',
|
||||
showOrderStats: 'true',
|
||||
lineStyle: 'single',
|
||||
printMargins: JSON.stringify({ left: 0, right: 0 }),
|
||||
showCustomerInfo: 'true',
|
||||
showOrderItems: 'true',
|
||||
showPrices: 'true',
|
||||
showTimestamps: 'true'
|
||||
};
|
||||
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)');
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
insert.run(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Config operations
|
||||
getConfig(key = null) {
|
||||
if (key) {
|
||||
const row = this.db.prepare('SELECT value FROM config WHERE key = ?').get(key);
|
||||
return row ? row.value : null;
|
||||
}
|
||||
|
||||
const rows = this.db.prepare('SELECT key, value FROM config').all();
|
||||
const config = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
setConfig(key, value) {
|
||||
this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
setConfigMultiple(configObj) {
|
||||
const insert = this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)');
|
||||
const transaction = this.db.transaction(() => {
|
||||
for (const [key, value] of Object.entries(configObj)) {
|
||||
insert.run(key, value);
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
// Order operations
|
||||
insertOrder(order) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return this.db.prepare(`
|
||||
INSERT INTO orders (order_id, bot_id, status, local_status, order_data, customer_data, total_amount, created_at, updated_at, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
order.id,
|
||||
order.botId,
|
||||
order.status,
|
||||
'new',
|
||||
JSON.stringify(order.order),
|
||||
JSON.stringify(order.customer),
|
||||
order.totalAmount,
|
||||
order.createdAt,
|
||||
order.updatedAt,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
getOrderById(orderId) {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM orders WHERE order_id = ?
|
||||
`).get(orderId);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.order_id,
|
||||
botId: row.bot_id,
|
||||
status: row.status,
|
||||
localStatus: row.local_status,
|
||||
order: JSON.parse(row.order_data),
|
||||
customer: JSON.parse(row.customer_data),
|
||||
totalAmount: row.total_amount,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
printedAt: row.printed_at,
|
||||
syncedAt: row.synced_at
|
||||
};
|
||||
}
|
||||
|
||||
getLastOrder() {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM orders ORDER BY order_id DESC LIMIT 1
|
||||
`).get();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
order_id: row.order_id,
|
||||
botId: row.bot_id,
|
||||
status: row.status,
|
||||
localStatus: row.local_status
|
||||
};
|
||||
}
|
||||
|
||||
updateOrder(order) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return this.db.prepare(`
|
||||
UPDATE orders
|
||||
SET status = ?, order_data = ?, customer_data = ?, total_amount = ?, updated_at = ?, synced_at = ?
|
||||
WHERE order_id = ?
|
||||
`).run(
|
||||
order.status,
|
||||
JSON.stringify(order.order),
|
||||
JSON.stringify(order.customer),
|
||||
order.totalAmount,
|
||||
order.updatedAt,
|
||||
now,
|
||||
order.id
|
||||
);
|
||||
}
|
||||
|
||||
updateOrderStatus(orderId, localStatus) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return this.db.prepare(`
|
||||
UPDATE orders SET local_status = ?, updated_at = ? WHERE order_id = ?
|
||||
`).run(localStatus, now, orderId);
|
||||
}
|
||||
|
||||
markOrderPrinted(orderId) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return this.db.prepare(`
|
||||
UPDATE orders SET printed_at = ? WHERE order_id = ?
|
||||
`).run(now, orderId);
|
||||
}
|
||||
|
||||
getOrders(filters = {}) {
|
||||
let query = 'SELECT * FROM orders WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (filters.status) {
|
||||
query += ' AND local_status = ?';
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
if (filters.date) {
|
||||
// Get orders from start of day to end of day
|
||||
const startOfDay = Math.floor(new Date(filters.date).setHours(0, 0, 0, 0) / 1000);
|
||||
const endOfDay = Math.floor(new Date(filters.date).setHours(23, 59, 59, 999) / 1000);
|
||||
query += ' AND created_at BETWEEN ? AND ?';
|
||||
params.push(startOfDay, endOfDay);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
if (filters.limit) {
|
||||
query += ' LIMIT ?';
|
||||
params.push(filters.limit);
|
||||
}
|
||||
|
||||
const rows = this.db.prepare(query).all(...params);
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.order_id,
|
||||
botId: row.bot_id,
|
||||
status: row.status,
|
||||
localStatus: row.local_status,
|
||||
order: JSON.parse(row.order_data),
|
||||
customer: JSON.parse(row.customer_data),
|
||||
totalAmount: row.total_amount,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
printedAt: row.printed_at,
|
||||
syncedAt: row.synced_at
|
||||
}));
|
||||
}
|
||||
|
||||
getOrderStats() {
|
||||
const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000);
|
||||
|
||||
const stats = {
|
||||
total: 0,
|
||||
new: 0,
|
||||
preparing: 0,
|
||||
ready: 0
|
||||
};
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT local_status, COUNT(*) as count
|
||||
FROM orders
|
||||
WHERE created_at >= ?
|
||||
GROUP BY local_status
|
||||
`).all(today);
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.local_status === 'new') stats.new = row.count;
|
||||
else if (row.local_status === 'preparing') stats.preparing = row.count;
|
||||
else if (row.local_status === 'ready') stats.ready = row.count;
|
||||
stats.total += row.count;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Print queue operations
|
||||
addToPrintQueue(orderId, printType) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const recentWindowSeconds = 300; // 5 minutes window to prevent duplicates
|
||||
const cutoff = now - recentWindowSeconds;
|
||||
|
||||
// Enhanced idempotency: check for any recent job (pending, processing, or recently completed)
|
||||
// Apply time window to pending/processing as well to avoid stale blocks
|
||||
const existing = this.db.prepare(`
|
||||
SELECT id, status, created_at FROM print_queue
|
||||
WHERE order_id = ? AND print_type = ?
|
||||
AND ((status IN ('pending','processing') AND created_at > ?) OR (status = 'completed' AND created_at > ?))
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`).get(orderId, printType, cutoff, cutoff);
|
||||
|
||||
if (existing && existing.id) {
|
||||
// If there's a pending or processing job, reuse it
|
||||
if (existing.status === 'pending' || existing.status === 'processing') {
|
||||
console.log(`[DB] Reusing existing ${existing.status} job ${existing.id} for order #${orderId} (${printType})`);
|
||||
return existing.id;
|
||||
}
|
||||
// If there's a recently completed job, allow creating a new job for audit
|
||||
if (existing.status === 'completed') {
|
||||
console.log(`[DB] Recent completed job ${existing.id} exists for order #${orderId} (${printType}); creating a new queued job.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DB] Creating new print job for order #${orderId} (${printType})`);
|
||||
const info = this.db.prepare(`
|
||||
INSERT INTO print_queue (order_id, print_type, status, created_at)
|
||||
VALUES (?, ?, 'pending', ?)
|
||||
`).run(orderId, printType, now);
|
||||
return info && info.lastInsertRowid ? info.lastInsertRowid : null;
|
||||
}
|
||||
|
||||
getPendingPrintJobs() {
|
||||
return this.db.prepare(`
|
||||
SELECT * FROM print_queue WHERE status = 'pending' ORDER BY created_at ASC
|
||||
`).all();
|
||||
}
|
||||
|
||||
markPrintJobCompleted(id) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return this.db.prepare(`
|
||||
UPDATE print_queue SET status = 'completed', printed_at = ? WHERE id = ?
|
||||
`).run(now, id);
|
||||
}
|
||||
|
||||
markPrintJobFailed(id) {
|
||||
return this.db.prepare(`
|
||||
UPDATE print_queue SET status = 'failed' WHERE id = ?
|
||||
`).run(id);
|
||||
}
|
||||
|
||||
// Transition a job to processing to prevent the worker from picking it up concurrently
|
||||
markPrintJobProcessing(id) {
|
||||
return this.db.prepare(`
|
||||
UPDATE print_queue SET status = 'processing' WHERE id = ?
|
||||
`).run(id);
|
||||
}
|
||||
|
||||
// Revert a job back to pending (e.g., if immediate print failed) so the worker can retry
|
||||
markPrintJobPending(id) {
|
||||
return this.db.prepare(`
|
||||
UPDATE print_queue SET status = 'pending' WHERE id = ?
|
||||
`).run(id);
|
||||
}
|
||||
|
||||
hasPrintedCancellation(orderId) {
|
||||
const row = this.db.prepare(`
|
||||
SELECT 1 FROM print_queue
|
||||
WHERE order_id = ? AND print_type = 'canceled' AND status = 'completed'
|
||||
LIMIT 1
|
||||
`).get(orderId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
// Cleanup other jobs for the same order+type when one succeeds
|
||||
// This prevents duplicate prints from multiple pending jobs
|
||||
cleanupDuplicateJobs(successfulJobId, orderId, printType) {
|
||||
const result = this.db.prepare(`
|
||||
UPDATE print_queue
|
||||
SET status = 'completed', printed_at = ?
|
||||
WHERE order_id = ? AND print_type = ?
|
||||
AND id != ?
|
||||
AND status IN ('pending', 'processing')
|
||||
`).run(Math.floor(Date.now() / 1000), orderId, printType, successfulJobId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
console.log(`[DB] Cleaned up ${result.changes} duplicate job(s) for order #${orderId} (${printType})`);
|
||||
}
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// Check if there's any active or recently completed job for order+type
|
||||
hasActiveOrRecentJob(orderId, printType, windowSeconds = 60) {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - windowSeconds;
|
||||
const row = this.db.prepare(`
|
||||
SELECT id, status FROM print_queue
|
||||
WHERE order_id = ? AND print_type = ?
|
||||
AND ((status IN ('processing', 'pending') AND created_at > ?) OR (status = 'completed' AND printed_at > ?))
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`).get(orderId, printType, cutoff, cutoff);
|
||||
return row ? { hasActive: true, jobId: row.id, status: row.status } : { hasActive: false };
|
||||
}
|
||||
|
||||
// Reset stale 'processing' jobs to 'pending' so the worker can pick them up
|
||||
// Useful when immediate prints failed (e.g., printer was offline) and jobs got stuck
|
||||
resetStuckProcessingJobs(maxAgeSeconds = 120) {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - maxAgeSeconds;
|
||||
const result = this.db.prepare(`
|
||||
UPDATE print_queue
|
||||
SET status = 'pending'
|
||||
WHERE status = 'processing' AND created_at < ?
|
||||
`).run(cutoff);
|
||||
if (result && result.changes > 0) {
|
||||
console.log(`[DB] Recovered ${result.changes} stuck processing job(s) to pending`);
|
||||
}
|
||||
return result && result.changes ? result.changes : 0;
|
||||
}
|
||||
|
||||
// Get count of recently completed jobs for debugging
|
||||
getRecentlyCompletedJobCount(orderId, printType, windowSeconds = 300) {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - windowSeconds;
|
||||
const row = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM print_queue
|
||||
WHERE order_id = ? AND print_type = ?
|
||||
AND status = 'completed'
|
||||
AND printed_at > ?
|
||||
`).get(orderId, printType, cutoff);
|
||||
return row ? row.count : 0;
|
||||
}
|
||||
|
||||
// Printer CRUD operations
|
||||
addPrinter(config) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO printers (
|
||||
name, type, interface, printer_type, paper_width, paper_format,
|
||||
is_default, is_enabled, font_size, line_style,
|
||||
qr_code_enabled, qr_code_size, qr_code_correction, qr_code_content_template,
|
||||
header_text, footer_text,
|
||||
business_name, business_address, business_phone, business_website, business_email,
|
||||
business_contact_size, show_customer_info, show_order_items, show_prices, show_timestamps,
|
||||
logo_path, logo_max_width_dots, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
config.name || 'Unnamed Printer',
|
||||
config.type || 'usb',
|
||||
config.interface || '',
|
||||
config.printer_type || 'epson',
|
||||
config.paper_width || 48,
|
||||
config.paper_format || '80mm',
|
||||
config.is_default ? 1 : 0,
|
||||
config.is_enabled !== false ? 1 : 0,
|
||||
config.font_size || 'normal',
|
||||
config.line_style || 'single',
|
||||
config.qr_code_enabled !== false ? 1 : 0,
|
||||
config.qr_code_size || 3,
|
||||
config.qr_code_correction || 'M',
|
||||
config.qr_code_content_template || 'ORDER-{id}',
|
||||
(typeof config.header_text !== 'undefined' ? config.header_text : 'KITCHEN ORDER'),
|
||||
(typeof config.footer_text !== 'undefined' ? config.footer_text : 'Thank you!'),
|
||||
config.business_name || '',
|
||||
config.business_address || '',
|
||||
config.business_phone || '',
|
||||
config.business_website || '',
|
||||
config.business_email || '',
|
||||
config.business_contact_size || 'normal',
|
||||
config.show_customer_info !== false ? 1 : 0,
|
||||
config.show_order_items !== false ? 1 : 0,
|
||||
config.show_prices !== false ? 1 : 0,
|
||||
config.show_timestamps !== false ? 1 : 0,
|
||||
config.logo_path || null,
|
||||
config.logo_max_width_dots || null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
// If this is set as default, unset other defaults
|
||||
if (config.is_default) {
|
||||
this.db.prepare('UPDATE printers SET is_default = 0 WHERE id != ?').run(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
return info.lastInsertRowid;
|
||||
}
|
||||
|
||||
updatePrinter(id, config) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE printers SET
|
||||
name = ?, type = ?, interface = ?, printer_type = ?, paper_width = ?, paper_format = ?,
|
||||
is_default = ?, is_enabled = ?, font_size = ?, line_style = ?,
|
||||
qr_code_enabled = ?, qr_code_size = ?, qr_code_correction = ?, qr_code_content_template = ?,
|
||||
header_text = ?, footer_text = ?,
|
||||
business_name = ?, business_address = ?, business_phone = ?, business_website = ?, business_email = ?,
|
||||
business_contact_size = ?, show_customer_info = ?, show_order_items = ?, show_prices = ?, show_timestamps = ?,
|
||||
logo_path = ?, logo_max_width_dots = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
config.name || 'Unnamed Printer',
|
||||
config.type || 'usb',
|
||||
config.interface || '',
|
||||
config.printer_type || 'epson',
|
||||
config.paper_width || 48,
|
||||
config.paper_format || '80mm',
|
||||
config.is_default ? 1 : 0,
|
||||
config.is_enabled !== false ? 1 : 0,
|
||||
config.font_size || 'normal',
|
||||
config.line_style || 'single',
|
||||
config.qr_code_enabled !== false ? 1 : 0,
|
||||
config.qr_code_size || 3,
|
||||
config.qr_code_correction || 'M',
|
||||
config.qr_code_content_template || 'ORDER-{id}',
|
||||
(typeof config.header_text !== 'undefined' ? config.header_text : 'KITCHEN ORDER'),
|
||||
(typeof config.footer_text !== 'undefined' ? config.footer_text : 'Thank you!'),
|
||||
config.business_name || '',
|
||||
config.business_address || '',
|
||||
config.business_phone || '',
|
||||
config.business_website || '',
|
||||
config.business_email || '',
|
||||
config.business_contact_size || 'normal',
|
||||
config.show_customer_info !== false ? 1 : 0,
|
||||
config.show_order_items !== false ? 1 : 0,
|
||||
config.show_prices !== false ? 1 : 0,
|
||||
config.show_timestamps !== false ? 1 : 0,
|
||||
config.logo_path || null,
|
||||
config.logo_max_width_dots || null,
|
||||
now,
|
||||
id
|
||||
);
|
||||
|
||||
// If this is set as default, unset other defaults
|
||||
if (config.is_default) {
|
||||
this.db.prepare('UPDATE printers SET is_default = 0 WHERE id != ?').run(id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
deletePrinter(id) {
|
||||
return this.db.prepare('DELETE FROM printers WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
getPrinter(id) {
|
||||
const row = this.db.prepare('SELECT * FROM printers WHERE id = ?').get(id);
|
||||
return row ? this.mapPrinterRow(row) : null;
|
||||
}
|
||||
|
||||
getAllPrinters() {
|
||||
const rows = this.db.prepare('SELECT * FROM printers ORDER BY is_default DESC, name ASC').all();
|
||||
return rows.map(row => this.mapPrinterRow(row));
|
||||
}
|
||||
|
||||
getEnabledPrinters() {
|
||||
const rows = this.db.prepare('SELECT * FROM printers WHERE is_enabled = 1 ORDER BY is_default DESC, name ASC').all();
|
||||
return rows.map(row => this.mapPrinterRow(row));
|
||||
}
|
||||
|
||||
getDefaultPrinter() {
|
||||
const row = this.db.prepare('SELECT * FROM printers WHERE is_default = 1 LIMIT 1').get();
|
||||
return row ? this.mapPrinterRow(row) : null;
|
||||
}
|
||||
|
||||
setDefaultPrinter(id) {
|
||||
const transaction = this.db.transaction(() => {
|
||||
this.db.prepare('UPDATE printers SET is_default = 0').run();
|
||||
this.db.prepare('UPDATE printers SET is_default = 1 WHERE id = ?').run(id);
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
togglePrinterEnabled(id) {
|
||||
const printer = this.getPrinter(id);
|
||||
if (!printer) return null;
|
||||
const newEnabled = printer.is_enabled ? 0 : 1;
|
||||
this.db.prepare('UPDATE printers SET is_enabled = ? WHERE id = ?').run(newEnabled, id);
|
||||
return { is_enabled: newEnabled === 1 };
|
||||
}
|
||||
|
||||
mapPrinterRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
interface: row.interface,
|
||||
printer_type: row.printer_type,
|
||||
paper_width: row.paper_width,
|
||||
paper_format: row.paper_format,
|
||||
is_default: row.is_default === 1,
|
||||
is_enabled: row.is_enabled === 1,
|
||||
font_size: row.font_size,
|
||||
line_style: row.line_style,
|
||||
qr_code_enabled: row.qr_code_enabled === 1,
|
||||
qr_code_size: row.qr_code_size,
|
||||
qr_code_correction: row.qr_code_correction,
|
||||
qr_code_content_template: row.qr_code_content_template,
|
||||
header_text: row.header_text,
|
||||
footer_text: row.footer_text,
|
||||
business_name: row.business_name,
|
||||
business_address: row.business_address,
|
||||
business_phone: row.business_phone,
|
||||
business_website: row.business_website,
|
||||
business_email: row.business_email,
|
||||
business_contact_size: row.business_contact_size,
|
||||
show_customer_info: row.show_customer_info === 1,
|
||||
show_order_items: row.show_order_items === 1,
|
||||
show_prices: row.show_prices === 1,
|
||||
show_timestamps: row.show_timestamps === 1,
|
||||
logo_path: row.logo_path,
|
||||
logo_max_width_dots: row.logo_max_width_dots,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
// Migration from old config system to new printers table
|
||||
migrateOldPrinterConfig() {
|
||||
try {
|
||||
// Check if we already have printers
|
||||
const existingPrinters = this.db.prepare('SELECT COUNT(*) as count FROM printers').get();
|
||||
if (existingPrinters && existingPrinters.count > 0) {
|
||||
return; // Already migrated
|
||||
}
|
||||
|
||||
// Get old config
|
||||
const oldConfig = this.getConfig();
|
||||
|
||||
// Only migrate if old printer config exists
|
||||
if (!oldConfig.printerInterface && !oldConfig.printerPath) {
|
||||
return; // No old config to migrate
|
||||
}
|
||||
|
||||
console.log('Migrating old printer configuration to new printers table...');
|
||||
|
||||
// Create a printer from old config
|
||||
const printerConfig = {
|
||||
name: 'Default Printer (Migrated)',
|
||||
type: oldConfig.printerInterface === 'serial' ? 'com' : (oldConfig.printerInterface || 'usb'),
|
||||
interface: oldConfig.printerPath || '/dev/usb/lp0',
|
||||
printer_type: oldConfig.printerType || 'epson',
|
||||
paper_width: parseInt(oldConfig.printerWidth, 10) || 48,
|
||||
paper_format: (parseInt(oldConfig.printerWidth, 10) || 48) >= 48 ? '80mm' : '58mm',
|
||||
is_default: true,
|
||||
is_enabled: true,
|
||||
font_size: oldConfig.fontSize || 'normal',
|
||||
line_style: oldConfig.lineStyle || 'single',
|
||||
qr_code_enabled: oldConfig.qrCodeEnabled !== 'false',
|
||||
qr_code_size: parseInt(oldConfig.qrCodeSize, 10) || 3,
|
||||
qr_code_correction: oldConfig.qrCodeCorrection || 'M',
|
||||
qr_code_content_template: oldConfig.qrCodeContentTemplate || 'ORDER-{id}',
|
||||
header_text: oldConfig.headerText || 'KITCHEN ORDER',
|
||||
footer_text: oldConfig.footerText || 'Thank you!',
|
||||
business_name: oldConfig.businessName || '',
|
||||
business_address: oldConfig.businessAddress || '',
|
||||
business_phone: oldConfig.businessPhone || '',
|
||||
business_website: oldConfig.businessWebsite || '',
|
||||
business_email: oldConfig.businessEmail || '',
|
||||
business_contact_size: oldConfig.businessContactSize || 'normal',
|
||||
show_customer_info: oldConfig.showCustomerInfo !== 'false',
|
||||
show_order_items: oldConfig.showOrderItems !== 'false',
|
||||
show_prices: oldConfig.showPrices !== 'false',
|
||||
show_timestamps: oldConfig.showTimestamps !== 'false',
|
||||
logo_path: oldConfig.logoPath || null,
|
||||
logo_max_width_dots: oldConfig.logoMaxWidthDots ? parseInt(oldConfig.logoMaxWidthDots, 10) : null
|
||||
};
|
||||
|
||||
this.addPrinter(printerConfig);
|
||||
console.log('Migration complete: Old printer configuration transferred to new system');
|
||||
} catch (error) {
|
||||
console.error('Error migrating old printer config:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DatabaseManager();
|
||||
|
||||
17
ecosystem.config.js
Normal file
17
ecosystem.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'kitchen-agent',
|
||||
script: './server.js',
|
||||
instances: 1,
|
||||
exec_mode: 'cluster',
|
||||
kill_timeout: 8000,
|
||||
listen_timeout: 8000,
|
||||
env: {
|
||||
REPO_URL: 'https://repo.cloud.thinklink.ai/thinklink/kitchen-agent',
|
||||
REPO_BRANCH: 'main',
|
||||
PM2_APP: 'kitchen-agent',
|
||||
UPDATE_CHECK_INTERVAL_MS: 300000
|
||||
// GITEA_TOKEN: ''
|
||||
}
|
||||
}]
|
||||
};
|
||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "kitchen-agent",
|
||||
"version": "1.0.5",
|
||||
"description": "Kitchen Agent for ThinkLink Food Order Management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"test": "node run-tests.js"
|
||||
},
|
||||
"keywords": ["kitchen", "food-order", "thermal-printer", "thinklink"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify": "^4.26.0",
|
||||
"@fastify/view": "^8.2.0",
|
||||
"@fastify/static": "^6.12.0",
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/formbody": "^7.4.0",
|
||||
"@fastify/multipart": "^8.1.0",
|
||||
"ejs": "^3.1.9",
|
||||
"node-thermal-printer": "^4.4.5",
|
||||
"better-sqlite3": "^9.4.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"pdf-to-printer": "^5.4.0",
|
||||
"pdfkit": "^0.15.0",
|
||||
"serialport": "^12.0.0",
|
||||
"auto-git-update": "^1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
157
print-worker.js
Normal file
157
print-worker.js
Normal file
@@ -0,0 +1,157 @@
|
||||
const config = require('./config');
|
||||
const database = require('./database');
|
||||
const printer = require('./printer');
|
||||
|
||||
class PrintQueueWorker {
|
||||
constructor(db = database, cfg = config, printerManager = printer) {
|
||||
this.db = db;
|
||||
this.cfg = cfg;
|
||||
this.printer = printerManager;
|
||||
this.intervalId = null;
|
||||
this.isRunning = false;
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
start(intervalMs = 10000) {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
const configured = parseInt(this.cfg.get('printWorkerInterval'), 10);
|
||||
const tickMs = Number.isFinite(configured) && configured > 0 ? configured : intervalMs;
|
||||
console.log(`[PrintQueueWorker] Starting with interval ${tickMs} ms`);
|
||||
this._schedule(tickMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.intervalId) {
|
||||
clearTimeout(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
console.log('[PrintQueueWorker] Stopped');
|
||||
}
|
||||
|
||||
_schedule(intervalMs) {
|
||||
if (!this.isRunning) return;
|
||||
this.intervalId = setTimeout(async () => {
|
||||
try {
|
||||
await this._tick();
|
||||
} catch (err) {
|
||||
console.error('[PrintQueueWorker] Tick error:', err.message);
|
||||
} finally {
|
||||
this._schedule(intervalMs);
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
async _tick() {
|
||||
if (this.isProcessing) {
|
||||
return; // avoid overlapping runs
|
||||
}
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
// Skip if no printers likely reachable to avoid hammering
|
||||
try {
|
||||
const printerConfigs = this.db.getEnabledPrinters();
|
||||
const anyReachable = await this.printer.anyConfiguredPrinterReachable(printerConfigs);
|
||||
if (!anyReachable) {
|
||||
console.log('[PrintQueueWorker] No reachable printers detected yet, will retry later');
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// If reachability check fails, proceed and let print attempt decide
|
||||
}
|
||||
|
||||
// Recover stuck processing jobs older than 2 minutes
|
||||
try { this.db.resetStuckProcessingJobs(120); } catch (_) {}
|
||||
|
||||
const pending = this.db.getPendingPrintJobs();
|
||||
if (!pending || pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log(`[PrintQueueWorker] Processing ${pending.length} pending print job(s)`);
|
||||
|
||||
for (const job of pending) {
|
||||
try {
|
||||
const order = this.db.getOrderById(job.order_id);
|
||||
if (!order) {
|
||||
console.warn(`[PrintQueueWorker] Order ${job.order_id} not found, marking job ${job.id} failed`);
|
||||
this.db.markPrintJobFailed(job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 1: If the order has been printed after the job was created, mark job completed to avoid duplicates
|
||||
if (order.printedAt && job.created_at && Number(order.printedAt) >= Number(job.created_at)) {
|
||||
console.log(`[PrintQueueWorker] Skipping job ${job.id}, order ${order.id} already printed at ${order.printedAt}`);
|
||||
this.db.markPrintJobCompleted(job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 2: Verify there's no active job (pending/processing) for this order+type (within last 60 seconds)
|
||||
// Do NOT block reprints because of a recently COMPLETED job; users may intentionally reprint.
|
||||
const activeCheck = this.db.hasActiveOrRecentJob(job.order_id, job.print_type, 60);
|
||||
if (activeCheck.hasActive && activeCheck.jobId !== job.id) {
|
||||
// Skip if another active pending/processing job exists
|
||||
if (activeCheck.status === 'pending' || activeCheck.status === 'processing') {
|
||||
console.log(`[PrintQueueWorker] Skipping job ${job.id}, another active job ${activeCheck.jobId} (${activeCheck.status}) exists for order ${job.order_id} (${job.print_type})`);
|
||||
this.db.markPrintJobCompleted(job.id);
|
||||
continue;
|
||||
}
|
||||
// If the recent job is COMPLETED, only block for non-reprint types (avoid duplicate cancellations/new)
|
||||
if (activeCheck.status === 'completed' && job.print_type !== 'reprint') {
|
||||
console.log(`[PrintQueueWorker] Skipping job ${job.id}, recent completed job ${activeCheck.jobId} exists for order ${job.order_id} (${job.print_type})`);
|
||||
this.db.markPrintJobCompleted(job.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: For cancellations, verify we haven't already printed one
|
||||
if (job.print_type === 'canceled' && this.db.hasPrintedCancellation(job.order_id)) {
|
||||
console.log(`[PrintQueueWorker] Skipping job ${job.id}, cancellation already printed for order ${job.order_id}`);
|
||||
this.db.markPrintJobCompleted(job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let type = job.print_type;
|
||||
if (type === 'reprint') {
|
||||
type = order.localStatus === 'canceled' ? 'canceled' : 'new';
|
||||
}
|
||||
|
||||
const printerConfigs = this.db.getEnabledPrinters();
|
||||
const cancelReason = (order && (order.cancellationReason || (order.order && order.order.cancellationReason))) || 'Order canceled';
|
||||
const options = type === 'canceled' ? { reason: cancelReason } : {};
|
||||
|
||||
let result;
|
||||
if (printerConfigs && printerConfigs.length > 0) {
|
||||
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, type, options);
|
||||
} else {
|
||||
const appConfig = this.cfg.getAll();
|
||||
if (!this.printer.printer || !this.printer.config) {
|
||||
try { this.printer.initializePrinter(appConfig); } catch (_) {}
|
||||
}
|
||||
result = await this.printer.printOrderReceipt(order, type, options);
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
this.db.markOrderPrinted(order.id);
|
||||
this.db.markPrintJobCompleted(job.id);
|
||||
// Cleanup any other pending/processing jobs for this order+type to prevent duplicates
|
||||
this.db.cleanupDuplicateJobs(job.id, job.order_id, job.print_type);
|
||||
console.log(`[PrintQueueWorker] ✓ Printed order #${order.id} (job ${job.id})`);
|
||||
} else {
|
||||
// Keep as pending for retry on next tick
|
||||
const errMsg = result && result.error ? result.error : 'Unknown print error';
|
||||
console.warn(`[PrintQueueWorker] ✗ Print failed for order #${order.id} (job ${job.id}): ${errMsg}`);
|
||||
}
|
||||
} catch (errJob) {
|
||||
console.error(`[PrintQueueWorker] Job ${job.id} error:`, errJob.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PrintQueueWorker;
|
||||
|
||||
|
||||
1757
printer.js
Normal file
1757
printer.js
Normal file
File diff suppressed because it is too large
Load Diff
1604
public/css/style.css
Normal file
1604
public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
2
public/images/.gitkeep
Normal file
2
public/images/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file ensures the images directory is created
|
||||
|
||||
59
public/js/common.js
Normal file
59
public/js/common.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Common utilities and functions
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
if (!toast) return;
|
||||
|
||||
toast.textContent = message;
|
||||
toast.className = 'toast visible ' + type;
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('visible');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return '$' + (amount || 0).toFixed(2);
|
||||
}
|
||||
|
||||
// Tab switching functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetTab = this.dataset.tab;
|
||||
|
||||
// Remove active class from all buttons and contents
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and corresponding content
|
||||
this.classList.add('active');
|
||||
const targetContent = document.getElementById(targetTab + '-tab');
|
||||
if (targetContent) {
|
||||
targetContent.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
953
public/js/dashboard.js
Normal file
953
public/js/dashboard.js
Normal file
@@ -0,0 +1,953 @@
|
||||
// Dashboard functionality
|
||||
|
||||
let currentFilter = 'all';
|
||||
let currentOrderIdForCancel = null;
|
||||
let pendingStatusChange = {
|
||||
orderId: null,
|
||||
newStatus: null,
|
||||
currentStatus: null,
|
||||
orderData: null
|
||||
};
|
||||
|
||||
// Track orders for notification detection
|
||||
let previousOrderIds = new Set();
|
||||
let previousCanceledOrderIds = new Set();
|
||||
let isFirstLoad = true;
|
||||
|
||||
// Connection status tracking
|
||||
const connectionStatus = {
|
||||
local: {
|
||||
status: 'checking',
|
||||
lastCheck: null,
|
||||
consecutiveFailures: 0
|
||||
},
|
||||
api: {
|
||||
status: 'checking',
|
||||
lastCheck: null,
|
||||
consecutiveFailures: 0,
|
||||
responseTime: null
|
||||
}
|
||||
};
|
||||
|
||||
// Audio notification system
|
||||
const audioNotification = {
|
||||
newOrderSound: null,
|
||||
canceledOrderSound: null,
|
||||
enabled: true,
|
||||
|
||||
init: function() {
|
||||
// Load notification settings from server
|
||||
fetch('/api/notification-settings')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
this.enabled = data.soundNotificationsEnabled !== 'false';
|
||||
const newOrderPath = data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3';
|
||||
const canceledOrderPath = data.canceledOrderSoundPath || '/public/sounds/new-order-notification.mp3';
|
||||
|
||||
// Preload audio files
|
||||
this.newOrderSound = new Audio(newOrderPath);
|
||||
this.canceledOrderSound = new Audio(canceledOrderPath);
|
||||
|
||||
// Set volume if specified
|
||||
if (data.soundVolume) {
|
||||
const volume = parseInt(data.soundVolume, 10) / 100;
|
||||
this.newOrderSound.volume = volume;
|
||||
this.canceledOrderSound.volume = volume;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Failed to load notification settings:', error);
|
||||
// Use default sound
|
||||
this.newOrderSound = new Audio('/public/sounds/new-order-notification.mp3');
|
||||
this.canceledOrderSound = new Audio('/public/sounds/new-order-notification.mp3');
|
||||
});
|
||||
},
|
||||
|
||||
playNewOrderSound: function() {
|
||||
if (this.enabled && this.newOrderSound) {
|
||||
this.newOrderSound.currentTime = 0;
|
||||
this.newOrderSound.play().catch(error => {
|
||||
console.warn('Failed to play new order sound:', error);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
playCanceledOrderSound: function() {
|
||||
if (this.enabled && this.canceledOrderSound) {
|
||||
this.canceledOrderSound.currentTime = 0;
|
||||
this.canceledOrderSound.play().catch(error => {
|
||||
console.warn('Failed to play canceled order sound:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize dashboard
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupFilterButtons();
|
||||
audioNotification.init();
|
||||
refreshOrders();
|
||||
|
||||
// Set up auto-refresh
|
||||
setInterval(refreshOrders, config.dashboardRefreshInterval || 10000);
|
||||
|
||||
// Initialize connection monitoring
|
||||
checkConnectionStatus();
|
||||
// Check connection status every 15 seconds
|
||||
setInterval(checkConnectionStatus, 15000);
|
||||
});
|
||||
|
||||
function setupFilterButtons() {
|
||||
const filterButtons = document.querySelectorAll('.filter-btn');
|
||||
|
||||
filterButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
// Remove active class from all buttons
|
||||
filterButtons.forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button
|
||||
this.classList.add('active');
|
||||
|
||||
// Update filter and refresh
|
||||
currentFilter = this.dataset.filter;
|
||||
refreshOrders();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshOrders() {
|
||||
const syncButton = document.getElementById('syncButton');
|
||||
const syncText = syncButton ? syncButton.querySelector('.sync-text') : null;
|
||||
|
||||
if (syncButton) {
|
||||
syncButton.classList.add('loading');
|
||||
syncButton.disabled = true;
|
||||
if (syncText) {
|
||||
syncText.textContent = 'Syncing...';
|
||||
}
|
||||
}
|
||||
|
||||
const statusParam = currentFilter === 'all' ? '' : currentFilter;
|
||||
const url = '/api/orders' + (statusParam ? '?status=' + statusParam : '');
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
updateOrderCards(data.orders);
|
||||
updateStats(data.stats);
|
||||
|
||||
// Connection successful - reset local connection failure counter
|
||||
if (connectionStatus.local.consecutiveFailures > 0) {
|
||||
connectionStatus.local.consecutiveFailures = 0;
|
||||
connectionStatus.local.status = 'online';
|
||||
updateConnectionUI('local', 'online', 'Connected');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to refresh orders:', error);
|
||||
|
||||
// Mark local connection as potentially offline
|
||||
connectionStatus.local.consecutiveFailures++;
|
||||
if (connectionStatus.local.consecutiveFailures >= 2) {
|
||||
connectionStatus.local.status = 'offline';
|
||||
updateConnectionUI('local', 'offline', 'Disconnected');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (syncButton) {
|
||||
syncButton.classList.remove('loading');
|
||||
syncButton.disabled = false;
|
||||
if (syncText) {
|
||||
syncText.textContent = 'Sync Now';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateStats(stats) {
|
||||
if (!stats) return;
|
||||
|
||||
const totalEl = document.getElementById('stat-total');
|
||||
const newEl = document.getElementById('stat-new');
|
||||
const preparingEl = document.getElementById('stat-preparing');
|
||||
const readyEl = document.getElementById('stat-ready');
|
||||
|
||||
if (totalEl) totalEl.textContent = stats.total || 0;
|
||||
if (newEl) newEl.textContent = stats.new || 0;
|
||||
if (preparingEl) preparingEl.textContent = stats.preparing || 0;
|
||||
if (readyEl) readyEl.textContent = stats.ready || 0;
|
||||
}
|
||||
|
||||
function updateOrderCards(orders) {
|
||||
const container = document.getElementById('ordersContainer');
|
||||
|
||||
if (!orders || orders.length === 0) {
|
||||
container.innerHTML = '<div class="no-orders">No orders to display</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect new orders and canceled orders for sound notifications
|
||||
if (!isFirstLoad) {
|
||||
const currentOrderIds = new Set();
|
||||
const currentCanceledOrderIds = new Set();
|
||||
|
||||
orders.forEach(order => {
|
||||
const orderId = order.id;
|
||||
const status = order.localStatus || order.status || 'new';
|
||||
|
||||
currentOrderIds.add(orderId);
|
||||
|
||||
if (status === 'canceled') {
|
||||
currentCanceledOrderIds.add(orderId);
|
||||
}
|
||||
|
||||
// Check for new orders (not in previous set)
|
||||
if (!previousOrderIds.has(orderId) && status === 'new') {
|
||||
console.log('New order detected:', orderId);
|
||||
audioNotification.playNewOrderSound();
|
||||
}
|
||||
|
||||
// Check for newly canceled orders
|
||||
if (!previousCanceledOrderIds.has(orderId) && status === 'canceled') {
|
||||
console.log('Order canceled:', orderId);
|
||||
audioNotification.playCanceledOrderSound();
|
||||
}
|
||||
});
|
||||
|
||||
// Update tracking sets
|
||||
previousOrderIds = currentOrderIds;
|
||||
previousCanceledOrderIds = currentCanceledOrderIds;
|
||||
} else {
|
||||
// On first load, just populate the tracking sets without playing sounds
|
||||
orders.forEach(order => {
|
||||
previousOrderIds.add(order.id);
|
||||
const status = order.localStatus || order.status || 'new';
|
||||
if (status === 'canceled') {
|
||||
previousCanceledOrderIds.add(order.id);
|
||||
}
|
||||
});
|
||||
isFirstLoad = false;
|
||||
}
|
||||
|
||||
container.innerHTML = orders.map(order => createOrderCard(order)).join('');
|
||||
}
|
||||
|
||||
function createOrderCard(order) {
|
||||
const status = order.localStatus || order.status || 'new';
|
||||
const statusBadge = getStatusBadge(status);
|
||||
const orderType = order.order.type || 'pickup';
|
||||
const typeIcon = orderType === 'delivery' ? '🚚' : '🛍️';
|
||||
|
||||
// Determine which actions to show based on status
|
||||
let actions = '';
|
||||
if (status === 'new') {
|
||||
actions = `
|
||||
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'preparing')">Start Preparing</button>
|
||||
`;
|
||||
} else if (status === 'preparing') {
|
||||
actions = `
|
||||
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'ready')">Mark Ready</button>
|
||||
`;
|
||||
} else if (status === 'ready') {
|
||||
actions = `
|
||||
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'completed')">Complete</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add cancel and reprint buttons for non-completed orders
|
||||
if (status !== 'canceled' && status !== 'completed') {
|
||||
actions += `
|
||||
<button class="btn btn-danger" onclick="openCancelModal(${order.id})">Cancel</button>
|
||||
`;
|
||||
}
|
||||
|
||||
actions += `
|
||||
<button class="btn btn-secondary" onclick="reprintOrder(${order.id})">Reprint</button>
|
||||
<button class="btn btn-secondary" onclick="showOrderDetails(${order.id})">Details</button>
|
||||
`;
|
||||
|
||||
// Build detailed items list with addons and excludes
|
||||
let itemsDetailedHtml = '';
|
||||
if (order.order.items && order.order.items.length > 0) {
|
||||
// Sort items alphabetically by name for better kitchen organization
|
||||
const sortedItems = [...order.order.items].sort((a, b) => {
|
||||
const nameA = (a.itemName || a.name || 'Item').toLowerCase();
|
||||
const nameB = (b.itemName || b.name || 'Item').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
itemsDetailedHtml = sortedItems.map(item => {
|
||||
let modifiersHtml = '';
|
||||
|
||||
// Add addons
|
||||
if (item.addons && item.addons.length > 0) {
|
||||
const addonsList = item.addons.map(a => a.name || a).join(', ');
|
||||
modifiersHtml += `<div class="item-modifier item-addon">+ Add: ${addonsList}</div>`;
|
||||
}
|
||||
|
||||
// Add excludes
|
||||
if (item.exclude && item.exclude.length > 0) {
|
||||
const excludeList = item.exclude.map(e => e.name || e).join(', ');
|
||||
modifiersHtml += `<div class="item-modifier item-exclude">− NO: ${excludeList}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="order-item">
|
||||
<div class="item-qty-name">
|
||||
<span class="item-qty">${item.qty}x</span>
|
||||
<span class="item-name">${item.itemName || item.name || 'Item'}</span>
|
||||
</div>
|
||||
${modifiersHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Food allergy warning (prominent)
|
||||
let allergyWarning = '';
|
||||
if (order.order.foodAllergy) {
|
||||
const allergyNotes = order.order.foodAllergyNotes || 'Customer has food allergies';
|
||||
allergyWarning = `
|
||||
<div class="allergy-warning">
|
||||
<div class="allergy-icon">⚠️</div>
|
||||
<div class="allergy-content">
|
||||
<div class="allergy-title">FOOD ALLERGY ALERT</div>
|
||||
<div class="allergy-notes">${allergyNotes}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Special instructions
|
||||
let specialInstructionsHtml = '';
|
||||
if (order.order.specialInstructions) {
|
||||
specialInstructionsHtml = `
|
||||
<div class="special-instructions">
|
||||
<div class="special-instructions-label">📝 Special Instructions:</div>
|
||||
<div class="special-instructions-text">${order.order.specialInstructions}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="order-card status-${status}">
|
||||
<div class="order-header">
|
||||
<div class="order-number">#${order.id}</div>
|
||||
<div class="order-badge badge-${status}">${statusBadge}</div>
|
||||
</div>
|
||||
|
||||
${allergyWarning}
|
||||
|
||||
<div class="order-info">
|
||||
<div class="order-info-row">
|
||||
<span class="order-info-label">Time:</span>
|
||||
<span class="order-info-value">${formatTime(order.createdAt)}</span>
|
||||
</div>
|
||||
<div class="order-info-row">
|
||||
<span class="order-info-label">Customer:</span>
|
||||
<span class="order-info-value">${order.customer.name || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="order-info-row">
|
||||
<span class="order-info-label">Type:</span>
|
||||
<span class="order-info-value"><span class="order-type-icon">${typeIcon}</span>${orderType}</span>
|
||||
</div>
|
||||
${order.order.deliveryAddress ? `<div class="order-info-row"><span class="order-info-label">Address:</span><span class="order-info-value">${order.order.deliveryAddress}</span></div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="order-items-section">
|
||||
<div class="order-items-title">Order Items:</div>
|
||||
${itemsDetailedHtml}
|
||||
</div>
|
||||
|
||||
${specialInstructionsHtml}
|
||||
|
||||
<div class="order-total">
|
||||
<span class="order-total-label">Total:</span>
|
||||
<span class="order-total-value">${formatCurrency(order.totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
<div class="order-actions">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getStatusBadge(status) {
|
||||
const badges = {
|
||||
'new': 'New',
|
||||
'preparing': 'Preparing',
|
||||
'ready': 'Ready',
|
||||
'completed': 'Completed',
|
||||
'canceled': 'Canceled'
|
||||
};
|
||||
return badges[status] || status;
|
||||
}
|
||||
|
||||
function changeStatus(orderId, newStatus) {
|
||||
// Fetch the order data first to show in the confirmation modal
|
||||
fetch(`/api/orders/${orderId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error && data.order) {
|
||||
openStatusChangeModal(orderId, newStatus, data.order);
|
||||
} else {
|
||||
showToast('Failed to fetch order details', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching order:', error);
|
||||
showToast('Failed to fetch order details', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function openStatusChangeModal(orderId, newStatus, orderData) {
|
||||
const currentStatus = orderData.localStatus || orderData.status || 'new';
|
||||
|
||||
// Store pending status change
|
||||
pendingStatusChange = {
|
||||
orderId: orderId,
|
||||
newStatus: newStatus,
|
||||
currentStatus: currentStatus,
|
||||
orderData: orderData
|
||||
};
|
||||
|
||||
// Get status information
|
||||
const statusInfo = getStatusInfo(newStatus);
|
||||
|
||||
// Update modal icon
|
||||
const iconEl = document.getElementById('statusModalIcon');
|
||||
iconEl.textContent = statusInfo.icon;
|
||||
iconEl.className = 'status-modal-icon ' + newStatus;
|
||||
|
||||
// Update modal title
|
||||
document.getElementById('statusModalTitle').textContent = statusInfo.title;
|
||||
|
||||
// Update order details
|
||||
document.getElementById('statusModalOrderId').textContent = orderId;
|
||||
document.getElementById('statusModalCustomer').textContent = orderData.customer.name || 'N/A';
|
||||
|
||||
// Update status badges
|
||||
const fromBadge = document.getElementById('statusModalFrom');
|
||||
const toBadge = document.getElementById('statusModalTo');
|
||||
|
||||
fromBadge.textContent = getStatusBadge(currentStatus);
|
||||
fromBadge.className = 'status-badge badge-' + currentStatus;
|
||||
|
||||
toBadge.textContent = getStatusBadge(newStatus);
|
||||
toBadge.className = 'status-badge badge-' + newStatus;
|
||||
|
||||
// Update message
|
||||
document.getElementById('statusModalMessage').textContent = statusInfo.message;
|
||||
|
||||
// Update confirm button
|
||||
const confirmBtn = document.getElementById('statusConfirmBtn');
|
||||
confirmBtn.textContent = statusInfo.buttonText;
|
||||
|
||||
// Show modal
|
||||
document.getElementById('statusChangeModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function getStatusInfo(status) {
|
||||
const statusMap = {
|
||||
'preparing': {
|
||||
icon: '👨🍳',
|
||||
title: 'Start Preparing Order?',
|
||||
message: 'This will mark the order as being prepared in the kitchen.',
|
||||
buttonText: 'Start Preparing'
|
||||
},
|
||||
'ready': {
|
||||
icon: '✅',
|
||||
title: 'Mark Order as Ready?',
|
||||
message: 'This will notify that the order is ready for pickup or delivery.',
|
||||
buttonText: 'Mark Ready'
|
||||
},
|
||||
'completed': {
|
||||
icon: '🎉',
|
||||
title: 'Complete Order?',
|
||||
message: 'This will mark the order as completed and it will be archived.',
|
||||
buttonText: 'Complete Order'
|
||||
}
|
||||
};
|
||||
|
||||
return statusMap[status] || {
|
||||
icon: '📋',
|
||||
title: 'Change Order Status?',
|
||||
message: 'This will update the order status.',
|
||||
buttonText: 'Confirm'
|
||||
};
|
||||
}
|
||||
|
||||
function closeStatusChangeModal() {
|
||||
document.getElementById('statusChangeModal').classList.remove('visible');
|
||||
pendingStatusChange = {
|
||||
orderId: null,
|
||||
newStatus: null,
|
||||
currentStatus: null,
|
||||
orderData: null
|
||||
};
|
||||
}
|
||||
|
||||
function confirmStatusChange() {
|
||||
if (!pendingStatusChange.orderId || !pendingStatusChange.newStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = pendingStatusChange.orderId;
|
||||
const newStatus = pendingStatusChange.newStatus;
|
||||
|
||||
// Close modal immediately for better UX
|
||||
closeStatusChangeModal();
|
||||
|
||||
// Make the API call
|
||||
fetch(`/api/orders/${orderId}/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
showToast(`Order #${orderId} updated to ${newStatus}`, 'success');
|
||||
refreshOrders();
|
||||
} else {
|
||||
showToast(data.message || 'Failed to update order', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating order:', error);
|
||||
showToast('Failed to update order', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function openCancelModal(orderId) {
|
||||
currentOrderIdForCancel = orderId;
|
||||
document.getElementById('cancelReason').value = '';
|
||||
document.getElementById('cancelModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeCancelModal() {
|
||||
currentOrderIdForCancel = null;
|
||||
document.getElementById('cancelModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
if (!currentOrderIdForCancel) return;
|
||||
|
||||
const reason = document.getElementById('cancelReason').value;
|
||||
|
||||
fetch(`/api/orders/${currentOrderIdForCancel}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason || 'Canceled by kitchen' })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
showToast(`Order #${currentOrderIdForCancel} canceled`, 'success');
|
||||
closeCancelModal();
|
||||
refreshOrders();
|
||||
} else {
|
||||
showToast(data.message || 'Failed to cancel order', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error canceling order:', error);
|
||||
showToast('Failed to cancel order', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
let reprintInProgressByOrder = {};
|
||||
function reprintOrder(orderId) {
|
||||
if (reprintInProgressByOrder[orderId]) {
|
||||
return; // guard against rapid double clicks
|
||||
}
|
||||
reprintInProgressByOrder[orderId] = true;
|
||||
fetch(`/api/orders/${orderId}/reprint`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
showToast((data && data.message) ? data.message : 'Receipt sent to printer', 'success');
|
||||
} else {
|
||||
showToast(data.message || 'Failed to print receipt', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error reprinting order:', error);
|
||||
showToast('Failed to print receipt', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
reprintInProgressByOrder[orderId] = false;
|
||||
});
|
||||
}
|
||||
|
||||
function showOrderDetails(orderId) {
|
||||
fetch(`/api/orders/${orderId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error && data.order) {
|
||||
displayOrderDetails(data.order);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching order details:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function displayOrderDetails(order) {
|
||||
const modal = document.getElementById('detailsModal');
|
||||
const content = document.getElementById('orderDetailsContent');
|
||||
|
||||
let itemsHtml = '';
|
||||
if (order.order.items && order.order.items.length > 0) {
|
||||
itemsHtml = order.order.items.map(item => {
|
||||
let modifiersHtml = '';
|
||||
|
||||
// Add addons with prices if available
|
||||
if (item.addons && item.addons.length > 0) {
|
||||
item.addons.forEach(addon => {
|
||||
const addonName = addon.name || addon;
|
||||
const addonPrice = typeof addon === 'object' && addon.price != null ? addon.price : null;
|
||||
|
||||
if (addonPrice != null && addonPrice > 0) {
|
||||
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName} <span style="color: #666;">(+${formatCurrency(addonPrice)})</span></small>`;
|
||||
} else if (addonPrice === 0) {
|
||||
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName} <span style="color: #666;">(Free)</span></small>`;
|
||||
} else {
|
||||
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName}</small>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add excludes (no price)
|
||||
if (item.exclude && item.exclude.length > 0) {
|
||||
item.exclude.forEach(exc => {
|
||||
const excName = exc.name || exc;
|
||||
modifiersHtml += `<br><small style="color: #dc3545; margin-left: 20px;">− NO ${excName}</small>`;
|
||||
});
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 8px;">${item.qty}x</td>
|
||||
<td style="padding: 8px;">
|
||||
<strong>${item.itemName || item.name}</strong>
|
||||
${modifiersHtml}
|
||||
</td>
|
||||
<td style="text-align: right; vertical-align: top; padding: 8px;">${formatCurrency(item.price * item.qty)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="order-details">
|
||||
<div class="detail-section">
|
||||
<h3>Order Information</h3>
|
||||
<p><strong>Order ID:</strong> ${order.id}</p>
|
||||
<p><strong>Status:</strong> ${getStatusBadge(order.localStatus || order.status)}</p>
|
||||
<p><strong>Time:</strong> ${formatDateTime(order.createdAt)}</p>
|
||||
<p><strong>Type:</strong> ${order.order.type || 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Customer Information</h3>
|
||||
<p><strong>Name:</strong> ${order.customer.name || 'N/A'}</p>
|
||||
<p><strong>Phone:</strong> ${order.customer.phoneNumber || 'N/A'}</p>
|
||||
${order.customer.email ? '<p><strong>Email:</strong> ' + order.customer.email + '</p>' : ''}
|
||||
${order.order.deliveryAddress ? '<p><strong>Address:</strong> ' + order.order.deliveryAddress + '</p>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Order Items</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #ddd;">
|
||||
<th style="text-align: left; padding: 8px;">Qty</th>
|
||||
<th style="text-align: left; padding: 8px;">Item</th>
|
||||
<th style="text-align: right; padding: 8px;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHtml}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="border-top: 2px solid #ddd;">
|
||||
<td colspan="2" style="text-align: right; padding: 8px;"><strong>Subtotal:</strong></td>
|
||||
<td style="text-align: right; padding: 8px;"><strong>${formatCurrency(order.order.amount)}</strong></td>
|
||||
</tr>
|
||||
${order.order.taxAmount ? `
|
||||
<tr>
|
||||
<td colspan="2" style="text-align: right; padding: 8px;">Tax (${order.order.taxRate || 0}%):</td>
|
||||
<td style="text-align: right; padding: 8px;">${formatCurrency(order.order.taxAmount)}</td>
|
||||
</tr>` : ''}
|
||||
${order.order.deliveryFee ? `
|
||||
<tr>
|
||||
<td colspan="2" style="text-align: right; padding: 8px;">Delivery Fee:</td>
|
||||
<td style="text-align: right; padding: 8px;">${formatCurrency(order.order.deliveryFee)}</td>
|
||||
</tr>` : ''}
|
||||
<tr style="border-top: 2px solid #333;">
|
||||
<td colspan="2" style="text-align: right; padding: 8px;"><strong>TOTAL:</strong></td>
|
||||
<td style="text-align: right; padding: 8px;"><strong>${formatCurrency(order.totalAmount)}</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${order.order.specialInstructions ? `
|
||||
<div class="detail-section">
|
||||
<h3>Special Instructions</h3>
|
||||
<p>${order.order.specialInstructions}</p>
|
||||
</div>` : ''}
|
||||
|
||||
${order.order.deliveryInstructions ? `
|
||||
<div class="detail-section">
|
||||
<h3>Delivery Instructions</h3>
|
||||
<p>${order.order.deliveryInstructions}</p>
|
||||
</div>` : ''}
|
||||
|
||||
${order.order.foodAllergy ? `
|
||||
<div class="detail-section" style="background: #ffe6e6; padding: 15px; border-radius: 4px;">
|
||||
<h3 style="color: #dc3545;">⚠ Food Allergy Warning</h3>
|
||||
<p><strong>${order.order.foodAllergyNotes || 'Customer has food allergies'}</strong></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function closeDetailsModal() {
|
||||
document.getElementById('detailsModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
// Close modals when clicking outside
|
||||
window.addEventListener('click', function(event) {
|
||||
const statusChangeModal = document.getElementById('statusChangeModal');
|
||||
const cancelModal = document.getElementById('cancelModal');
|
||||
const detailsModal = document.getElementById('detailsModal');
|
||||
|
||||
if (event.target === statusChangeModal) {
|
||||
closeStatusChangeModal();
|
||||
}
|
||||
|
||||
if (event.target === cancelModal) {
|
||||
closeCancelModal();
|
||||
}
|
||||
|
||||
if (event.target === detailsModal) {
|
||||
closeDetailsModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modals with Escape key
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeStatusChangeModal();
|
||||
closeCancelModal();
|
||||
closeDetailsModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Connection status monitoring
|
||||
function checkConnectionStatus() {
|
||||
// Check local connection (dashboard server)
|
||||
checkLocalConnection();
|
||||
|
||||
// Check external API connection
|
||||
checkAPIConnection();
|
||||
}
|
||||
|
||||
function checkLocalConnection() {
|
||||
const startTime = Date.now();
|
||||
|
||||
fetch('/api/health/local')
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw new Error('Server returned error status');
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
connectionStatus.local.status = 'online';
|
||||
connectionStatus.local.lastCheck = new Date();
|
||||
connectionStatus.local.consecutiveFailures = 0;
|
||||
updateConnectionUI('local', 'online', 'Connected');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Local connection check failed:', error);
|
||||
connectionStatus.local.consecutiveFailures++;
|
||||
|
||||
if (connectionStatus.local.consecutiveFailures >= 2) {
|
||||
connectionStatus.local.status = 'offline';
|
||||
updateConnectionUI('local', 'offline', 'Disconnected');
|
||||
|
||||
// Show warning toast on first detection of offline
|
||||
if (connectionStatus.local.consecutiveFailures === 2) {
|
||||
showToast('Dashboard server connection lost', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkAPIConnection() {
|
||||
const startTime = Date.now();
|
||||
|
||||
fetch('/api/health/external')
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw new Error('Server returned error status');
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
connectionStatus.api.lastCheck = new Date();
|
||||
|
||||
if (data.status === 'online') {
|
||||
// Was offline, now online - notify user
|
||||
if (connectionStatus.api.status === 'offline' && connectionStatus.api.consecutiveFailures >= 2) {
|
||||
showToast('API server connection restored', 'success');
|
||||
}
|
||||
|
||||
connectionStatus.api.status = 'online';
|
||||
connectionStatus.api.consecutiveFailures = 0;
|
||||
connectionStatus.api.responseTime = data.responseTime;
|
||||
|
||||
let label = 'Connected';
|
||||
if (data.responseTime) {
|
||||
label = `${data.responseTime}ms`;
|
||||
}
|
||||
updateConnectionUI('api', 'online', label);
|
||||
} else if (data.status === 'unconfigured') {
|
||||
connectionStatus.api.status = 'unconfigured';
|
||||
connectionStatus.api.consecutiveFailures = 0;
|
||||
updateConnectionUI('api', 'unconfigured', 'Not Configured');
|
||||
} else {
|
||||
connectionStatus.api.consecutiveFailures++;
|
||||
|
||||
if (connectionStatus.api.consecutiveFailures >= 2) {
|
||||
connectionStatus.api.status = 'offline';
|
||||
updateConnectionUI('api', 'offline', data.message || 'Disconnected');
|
||||
|
||||
// Show warning toast on first detection of offline
|
||||
if (connectionStatus.api.consecutiveFailures === 2) {
|
||||
showToast('API server connection lost: ' + (data.message || 'Disconnected'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('API connection check failed:', error);
|
||||
connectionStatus.api.consecutiveFailures++;
|
||||
|
||||
if (connectionStatus.api.consecutiveFailures >= 2) {
|
||||
connectionStatus.api.status = 'offline';
|
||||
updateConnectionUI('api', 'offline', 'Check Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateConnectionUI(type, status, label) {
|
||||
let statusItem, statusIndicator, statusLabel;
|
||||
|
||||
if (type === 'local') {
|
||||
statusItem = document.getElementById('localConnectionStatus');
|
||||
statusLabel = document.getElementById('localStatusLabel');
|
||||
} else if (type === 'api') {
|
||||
statusItem = document.getElementById('apiConnectionStatus');
|
||||
statusLabel = document.getElementById('apiStatusLabel');
|
||||
}
|
||||
|
||||
if (!statusItem || !statusLabel) return;
|
||||
|
||||
statusIndicator = statusItem.querySelector('.status-indicator');
|
||||
|
||||
// Remove all status classes
|
||||
statusIndicator.classList.remove('status-online', 'status-offline', 'status-checking', 'status-unconfigured');
|
||||
|
||||
// Add new status class
|
||||
statusIndicator.classList.add('status-' + status);
|
||||
|
||||
// Update label text
|
||||
statusLabel.textContent = label;
|
||||
|
||||
// Update title attribute for tooltip
|
||||
let tooltipText = '';
|
||||
if (type === 'local') {
|
||||
tooltipText = 'Dashboard Server: ';
|
||||
} else {
|
||||
tooltipText = 'API Server (api.thinklink.ai): ';
|
||||
}
|
||||
|
||||
tooltipText += label;
|
||||
if (connectionStatus[type].lastCheck) {
|
||||
const lastCheckTime = connectionStatus[type].lastCheck.toLocaleTimeString();
|
||||
tooltipText += ' (Last checked: ' + lastCheckTime + ')';
|
||||
}
|
||||
|
||||
statusItem.title = tooltipText;
|
||||
}
|
||||
|
||||
// Manual sync trigger
|
||||
function manualSync() {
|
||||
const syncButton = document.getElementById('syncButton');
|
||||
const syncText = syncButton ? syncButton.querySelector('.sync-text') : null;
|
||||
|
||||
// Prevent multiple simultaneous sync requests
|
||||
if (syncButton && syncButton.classList.contains('loading')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncButton) {
|
||||
syncButton.classList.add('loading');
|
||||
syncButton.disabled = true;
|
||||
if (syncText) {
|
||||
syncText.textContent = 'Syncing...';
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/sync-now', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
showToast('Checking for new orders...', 'success');
|
||||
// Refresh after a short delay to allow sync to complete
|
||||
setTimeout(() => {
|
||||
refreshOrders();
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.message || 'Sync failed', 'error');
|
||||
// Remove loading state on error
|
||||
if (syncButton) {
|
||||
syncButton.classList.remove('loading');
|
||||
syncButton.disabled = false;
|
||||
if (syncText) {
|
||||
syncText.textContent = 'Sync Now';
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Manual sync error:', error);
|
||||
showToast('Sync failed', 'error');
|
||||
// Remove loading state on error
|
||||
if (syncButton) {
|
||||
syncButton.classList.remove('loading');
|
||||
syncButton.disabled = false;
|
||||
if (syncText) {
|
||||
syncText.textContent = 'Sync Now';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
709
public/js/settings.js
Normal file
709
public/js/settings.js
Normal file
@@ -0,0 +1,709 @@
|
||||
// Settings page functionality
|
||||
|
||||
// ========== Legacy functions (kept for backward compatibility) ==========
|
||||
function testPrinter() {
|
||||
const resultEl = document.getElementById('printerTestResult');
|
||||
const selectedHidden = document.getElementById('selectedPrintersJson');
|
||||
resultEl.textContent = 'Testing...';
|
||||
resultEl.style.color = '#666';
|
||||
|
||||
const body = JSON.stringify({
|
||||
selectedPrintersJson: selectedHidden ? selectedHidden.value : '[]'
|
||||
});
|
||||
|
||||
fetch('/settings/test-printer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
resultEl.textContent = '✓ ' + data.message;
|
||||
resultEl.style.color = '#28a745';
|
||||
} else {
|
||||
resultEl.textContent = '✗ ' + (data.message || 'Test failed');
|
||||
resultEl.style.color = '#dc3545';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Test print error:', error);
|
||||
resultEl.textContent = '✗ Network error';
|
||||
resultEl.style.color = '#dc3545';
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadLogo() {
|
||||
const fileInput = document.getElementById('logoUpload');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a valid image file (PNG, JPG, or GIF)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
alert('Logo uploaded successfully!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error);
|
||||
alert('Upload failed: Network error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== New Printer Management Functions ==========
|
||||
|
||||
let currentPrinterId = null;
|
||||
|
||||
// Load and display printers
|
||||
async function loadPrinters() {
|
||||
const container = document.getElementById('printer-cards-container');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/printers/list');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error || !data.printers || data.printers.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-printers">
|
||||
<p>No printers configured yet.</p>
|
||||
<p>Click "Add Printer" to configure your first printer.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render printer cards
|
||||
const cardsHTML = data.printers.map(printer => createPrinterCard(printer)).join('');
|
||||
container.innerHTML = cardsHTML;
|
||||
} catch (error) {
|
||||
console.error('Failed to load printers:', error);
|
||||
container.innerHTML = `<div class="alert alert-error">Failed to load printers: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTML for a printer card
|
||||
function createPrinterCard(printer) {
|
||||
const defaultBadge = printer.is_default ? '<span class="printer-badge badge-default">DEFAULT</span>' : '';
|
||||
const enabledBadge = printer.is_enabled
|
||||
? '<span class="printer-badge badge-enabled">ENABLED</span>'
|
||||
: '<span class="printer-badge badge-disabled">DISABLED</span>';
|
||||
|
||||
const typeLabel = {
|
||||
'network': 'Network',
|
||||
'com': 'Serial/COM',
|
||||
'usb': 'USB',
|
||||
'system': 'System Printer'
|
||||
}[printer.type] || printer.type;
|
||||
|
||||
return `
|
||||
<div class="printer-card ${!printer.is_enabled ? 'disabled' : ''}">
|
||||
<div class="printer-card-header">
|
||||
<div>
|
||||
<h4>${escapeHtml(printer.name)}</h4>
|
||||
<div class="printer-badges">
|
||||
${defaultBadge}
|
||||
${enabledBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="printer-card-body">
|
||||
<div class="printer-info-row">
|
||||
<span class="printer-info-label">Connection:</span>
|
||||
<span class="printer-info-value">${typeLabel}: ${escapeHtml(printer.interface)}</span>
|
||||
</div>
|
||||
<div class="printer-info-row">
|
||||
<span class="printer-info-label">Paper:</span>
|
||||
<span class="printer-info-value">${printer.paper_format} (${printer.paper_width} chars) | ${printer.printer_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="printer-card-actions">
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="editPrinter(${printer.id})">Edit</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="testPrinterById(${printer.id})">Test</button>
|
||||
${!printer.is_default ? `<button type="button" class="btn btn-secondary btn-sm" onclick="setDefaultPrinter(${printer.id})">Set Default</button>` : ''}
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="togglePrinterEnabled(${printer.id})">${printer.is_enabled ? 'Disable' : 'Enable'}</button>
|
||||
${!printer.is_default ? `<button type="button" class="btn btn-danger btn-sm" onclick="deletePrinter(${printer.id})">Delete</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Open add printer modal
|
||||
function openAddPrinterModal() {
|
||||
currentPrinterId = null;
|
||||
document.getElementById('printerModalTitle').textContent = 'Add Printer';
|
||||
document.getElementById('printerConfigForm').reset();
|
||||
document.getElementById('printer_id').value = '';
|
||||
|
||||
// Set defaults
|
||||
document.getElementById('paper_width').value = 48;
|
||||
document.getElementById('paper_format').value = '80mm';
|
||||
document.getElementById('printer_type_model').value = 'epson';
|
||||
document.getElementById('font_size').value = 'normal';
|
||||
document.getElementById('line_style').value = 'single';
|
||||
document.getElementById('qr_code_size').value = 3;
|
||||
document.getElementById('qr_code_correction').value = 'M';
|
||||
document.getElementById('qr_code_content_template').value = 'ORDER-{id}';
|
||||
document.getElementById('header_text').value = '';
|
||||
document.getElementById('footer_text').value = '';
|
||||
document.getElementById('business_contact_size').value = 'normal';
|
||||
|
||||
// Pre-fill Business Information from Receipt Template tab
|
||||
try {
|
||||
const tplBusinessName = document.getElementById('businessName');
|
||||
const tplBusinessAddress = document.getElementById('businessAddress');
|
||||
const tplBusinessPhone = document.getElementById('businessPhone');
|
||||
const tplBusinessWebsite = document.getElementById('businessWebsite');
|
||||
const tplBusinessEmail = document.getElementById('businessEmail');
|
||||
const tplBusinessContactSize = document.getElementById('businessContactSize');
|
||||
|
||||
if (tplBusinessName) document.getElementById('business_name').value = tplBusinessName.value || '';
|
||||
if (tplBusinessAddress) document.getElementById('business_address').value = tplBusinessAddress.value || '';
|
||||
if (tplBusinessPhone) document.getElementById('business_phone').value = tplBusinessPhone.value || '';
|
||||
if (tplBusinessWebsite) document.getElementById('business_website').value = tplBusinessWebsite.value || '';
|
||||
if (tplBusinessEmail) document.getElementById('business_email').value = tplBusinessEmail.value || '';
|
||||
if (tplBusinessContactSize) document.getElementById('business_contact_size').value = tplBusinessContactSize.value || 'normal';
|
||||
} catch (_) {}
|
||||
|
||||
// Set checkboxes
|
||||
document.getElementById('show_customer_info').checked = true;
|
||||
document.getElementById('show_order_items').checked = true;
|
||||
document.getElementById('show_prices').checked = true;
|
||||
document.getElementById('show_timestamps').checked = true;
|
||||
document.getElementById('qr_code_enabled').checked = true;
|
||||
document.getElementById('is_enabled').checked = true;
|
||||
document.getElementById('is_default').checked = false;
|
||||
|
||||
showModal('printerConfigModal');
|
||||
switchPrinterModalTab('connection');
|
||||
}
|
||||
|
||||
// Edit printer
|
||||
async function editPrinter(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to load printer: ' + data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const printer = data.printer;
|
||||
currentPrinterId = id;
|
||||
|
||||
document.getElementById('printerModalTitle').textContent = 'Edit Printer';
|
||||
document.getElementById('printer_id').value = id;
|
||||
|
||||
// Fill form with printer data
|
||||
document.getElementById('printer_name').value = printer.name || '';
|
||||
document.getElementById('printer_type_select').value = printer.type || 'network';
|
||||
document.getElementById('printer_interface').value = printer.interface || '';
|
||||
document.getElementById('paper_format').value = printer.paper_format || '80mm';
|
||||
document.getElementById('paper_width').value = printer.paper_width || 48;
|
||||
document.getElementById('printer_type_model').value = printer.printer_type || 'epson';
|
||||
document.getElementById('font_size').value = printer.font_size || 'normal';
|
||||
document.getElementById('line_style').value = printer.line_style || 'single';
|
||||
|
||||
document.getElementById('header_text').value = printer.header_text || '';
|
||||
document.getElementById('footer_text').value = printer.footer_text || '';
|
||||
|
||||
document.getElementById('business_name').value = printer.business_name || '';
|
||||
document.getElementById('business_address').value = printer.business_address || '';
|
||||
document.getElementById('business_phone').value = printer.business_phone || '';
|
||||
document.getElementById('business_website').value = printer.business_website || '';
|
||||
document.getElementById('business_email').value = printer.business_email || '';
|
||||
document.getElementById('business_contact_size').value = printer.business_contact_size || 'normal';
|
||||
|
||||
document.getElementById('logo_path').value = printer.logo_path || '';
|
||||
document.getElementById('logo_max_width_dots').value = printer.logo_max_width_dots || '';
|
||||
|
||||
if (printer.logo_path) {
|
||||
document.getElementById('logo_preview').innerHTML = `<small>Current logo: ${printer.logo_path}</small>`;
|
||||
} else {
|
||||
document.getElementById('logo_preview').innerHTML = '';
|
||||
}
|
||||
|
||||
document.getElementById('qr_code_size').value = printer.qr_code_size || 3;
|
||||
document.getElementById('qr_code_correction').value = printer.qr_code_correction || 'M';
|
||||
document.getElementById('qr_code_content_template').value = printer.qr_code_content_template || 'ORDER-{id}';
|
||||
|
||||
document.getElementById('show_customer_info').checked = printer.show_customer_info !== false;
|
||||
document.getElementById('show_order_items').checked = printer.show_order_items !== false;
|
||||
document.getElementById('show_prices').checked = printer.show_prices !== false;
|
||||
document.getElementById('show_timestamps').checked = printer.show_timestamps !== false;
|
||||
document.getElementById('qr_code_enabled').checked = printer.qr_code_enabled !== false;
|
||||
document.getElementById('is_default').checked = printer.is_default || false;
|
||||
document.getElementById('is_enabled').checked = printer.is_enabled !== false;
|
||||
|
||||
showModal('printerConfigModal');
|
||||
switchPrinterModalTab('connection');
|
||||
} catch (error) {
|
||||
console.error('Failed to load printer:', error);
|
||||
alert('Failed to load printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Save printer configuration
|
||||
async function savePrinterConfig() {
|
||||
const form = document.getElementById('printerConfigForm');
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const config = {};
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'id' && !value) continue; // Skip empty id
|
||||
|
||||
// Handle checkboxes
|
||||
if (['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].includes(key)) {
|
||||
config[key] = document.getElementById(key).checked;
|
||||
} else if (key === 'paper_width' || key === 'qr_code_size' || key === 'logo_max_width_dots') {
|
||||
const val = parseInt(value, 10);
|
||||
if (!isNaN(val) && val > 0) config[key] = val;
|
||||
} else {
|
||||
config[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure checkbox fields are always present in payload (unchecked boxes are omitted from FormData by default)
|
||||
['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].forEach((key) => {
|
||||
const el = document.getElementById(key);
|
||||
if (el) {
|
||||
config[key] = !!el.checked;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const printerId = document.getElementById('printer_id').value;
|
||||
const url = printerId ? `/api/printers/${printerId}` : '/api/printers/create';
|
||||
const method = printerId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to save printer: ' + data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
alert(data.message || 'Printer saved successfully');
|
||||
closePrinterModal();
|
||||
loadPrinters();
|
||||
} catch (error) {
|
||||
console.error('Failed to save printer:', error);
|
||||
alert('Failed to save printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete printer
|
||||
async function deletePrinter(id) {
|
||||
if (!confirm('Are you sure you want to delete this printer?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to delete printer: ' + data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Printer deleted successfully');
|
||||
loadPrinters();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete printer:', error);
|
||||
alert('Failed to delete printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test printer
|
||||
async function testPrinterById(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}/test`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Test failed: ' + data.message);
|
||||
} else {
|
||||
alert(data.message || 'Test print sent successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test print error:', error);
|
||||
alert('Test failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Set default printer
|
||||
async function setDefaultPrinter(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}/set-default`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to set default: ' + data.message);
|
||||
} else {
|
||||
loadPrinters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set default:', error);
|
||||
alert('Failed to set default: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle printer enabled
|
||||
async function togglePrinterEnabled(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}/toggle-enabled`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to toggle printer: ' + data.message);
|
||||
} else {
|
||||
loadPrinters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle printer:', error);
|
||||
alert('Failed to toggle printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload logo for specific printer
|
||||
async function uploadLogoForPrinter() {
|
||||
const fileInput = document.getElementById('logo_upload_modal');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a valid image file (PNG, JPG, or GIF)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// If editing existing printer, include printer_id
|
||||
const printerId = document.getElementById('printer_id').value;
|
||||
if (printerId) {
|
||||
formData.append('printer_id', printerId);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
alert('Logo uploaded successfully');
|
||||
document.getElementById('logo_path').value = data.filepath;
|
||||
document.getElementById('logo_preview').innerHTML = `<small>Logo uploaded: ${data.filepath}</small>`;
|
||||
} else {
|
||||
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error);
|
||||
alert('Upload failed: Network error');
|
||||
}
|
||||
}
|
||||
|
||||
// Detect printers for modal
|
||||
async function detectPrintersForModal() {
|
||||
const listEl = document.getElementById('detected-printers-list');
|
||||
listEl.innerHTML = '<p>Detecting printers...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/printers/detect');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error || !data.printers || data.printers.length === 0) {
|
||||
listEl.innerHTML = '<p>No printers detected.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const items = data.printers.map(p => {
|
||||
const typeLabel = {
|
||||
'system': 'System',
|
||||
'com': 'COM'
|
||||
}[p.type] || p.type;
|
||||
|
||||
return `
|
||||
<div class="detected-printer-item">
|
||||
<span><strong>${escapeHtml(p.name)}</strong> (${typeLabel})</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="selectDetectedPrinter('${p.type}', '${escapeHtml(p.interface)}')">Use This</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
listEl.innerHTML = items;
|
||||
} catch (error) {
|
||||
console.error('Failed to detect printers:', error);
|
||||
listEl.innerHTML = '<p>Failed to detect printers.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Select a detected printer
|
||||
function selectDetectedPrinter(type, interface) {
|
||||
document.getElementById('printer_type_select').value = type;
|
||||
document.getElementById('printer_interface').value = interface;
|
||||
updateInterfaceHint();
|
||||
}
|
||||
|
||||
// Update interface hint based on connection type
|
||||
function updateInterfaceHint() {
|
||||
const type = document.getElementById('printer_type_select').value;
|
||||
const hintEl = document.getElementById('interface_hint');
|
||||
|
||||
const hints = {
|
||||
'network': 'Enter IP:Port for network printers (e.g., 192.168.1.100:9100)',
|
||||
'com': 'Enter COM port (e.g., COM1, COM3)',
|
||||
'usb': 'Enter USB device path (e.g., /dev/usb/lp0)',
|
||||
'system': 'Enter the exact printer name from Windows'
|
||||
};
|
||||
|
||||
hintEl.textContent = hints[type] || 'Enter connection address';
|
||||
}
|
||||
|
||||
// Auto-update paper width when format changes
|
||||
function updatePaperWidthFromFormat() {
|
||||
const format = document.getElementById('paper_format').value;
|
||||
const widthInput = document.getElementById('paper_width');
|
||||
|
||||
const widthMap = {
|
||||
'58mm': 32,
|
||||
'80mm': 48,
|
||||
'letter': 80
|
||||
};
|
||||
|
||||
if (widthMap[format]) {
|
||||
widthInput.value = widthMap[format];
|
||||
}
|
||||
}
|
||||
|
||||
// Modal tab switching
|
||||
function switchPrinterModalTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.getAttribute('data-tab') === tabName) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update tab content
|
||||
document.querySelectorAll('.printer-modal-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(tabName + '-tab-content').classList.add('active');
|
||||
}
|
||||
|
||||
// Close printer modal
|
||||
function closePrinterModal() {
|
||||
hideModal('printerConfigModal');
|
||||
currentPrinterId = null;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
function showModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('visible');
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
function hideModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('visible');
|
||||
}
|
||||
|
||||
// ========== Sound Notification Functions ==========
|
||||
|
||||
// Upload sound file
|
||||
async function uploadSound(soundType) {
|
||||
const fileInputId = soundType === 'newOrder' ? 'newOrderSoundUpload' : 'canceledOrderSoundUpload';
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a valid audio file (MP3, WAV, or OGG)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('File size must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('soundType', soundType);
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/upload-sound', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
alert('Sound uploaded successfully! Please save settings to apply changes.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sound upload error:', error);
|
||||
alert('Upload failed: Network error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test sound playback
|
||||
async function testSound(soundType) {
|
||||
try {
|
||||
// Get current settings
|
||||
const response = await fetch('/api/notification-settings');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to load sound settings');
|
||||
return;
|
||||
}
|
||||
|
||||
const soundPath = soundType === 'newOrder'
|
||||
? (data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3')
|
||||
: (data.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3');
|
||||
|
||||
const volumeInput = document.getElementById('soundVolume');
|
||||
const volume = volumeInput ? parseInt(volumeInput.value, 10) / 100 : 0.8;
|
||||
|
||||
const audio = new Audio(soundPath);
|
||||
audio.volume = volume;
|
||||
|
||||
audio.play().catch(error => {
|
||||
console.error('Failed to play sound:', error);
|
||||
alert('Failed to play sound. Make sure the file exists and is a valid audio file.');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Test sound error:', error);
|
||||
alert('Failed to test sound: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Update volume display
|
||||
function updateVolumeDisplay() {
|
||||
const volumeInput = document.getElementById('soundVolume');
|
||||
const volumeValue = document.getElementById('volumeValue');
|
||||
|
||||
if (volumeInput && volumeValue) {
|
||||
volumeValue.textContent = volumeInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Event Listeners ==========
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load printers on settings page
|
||||
if (document.getElementById('printer-cards-container')) {
|
||||
loadPrinters();
|
||||
}
|
||||
|
||||
// Printer modal tab switching
|
||||
document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
switchPrinterModalTab(this.getAttribute('data-tab'));
|
||||
});
|
||||
});
|
||||
|
||||
// Connection type change handler
|
||||
const typeSelect = document.getElementById('printer_type_select');
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', updateInterfaceHint);
|
||||
}
|
||||
|
||||
// Paper format change handler
|
||||
const formatSelect = document.getElementById('paper_format');
|
||||
if (formatSelect) {
|
||||
formatSelect.addEventListener('change', updatePaperWidthFromFormat);
|
||||
}
|
||||
|
||||
// Volume slider handler
|
||||
const volumeInput = document.getElementById('soundVolume');
|
||||
if (volumeInput) {
|
||||
volumeInput.addEventListener('input', updateVolumeDisplay);
|
||||
}
|
||||
|
||||
// Update interface placeholder based on interface type (legacy support)
|
||||
const interfaceSelect = document.getElementById('printerInterface');
|
||||
const pathInput = document.getElementById('printerPath');
|
||||
|
||||
if (interfaceSelect && pathInput) {
|
||||
interfaceSelect.addEventListener('change', function() {
|
||||
const placeholders = {
|
||||
'usb': '/dev/usb/lp0 (Linux) or COM1 (Windows)',
|
||||
'network': '192.168.1.100:9100',
|
||||
'serial': 'COM1 (Windows) or /dev/ttyS0 (Linux)'
|
||||
};
|
||||
|
||||
pathInput.placeholder = placeholders[this.value] || 'Enter printer path';
|
||||
});
|
||||
}
|
||||
});
|
||||
BIN
public/sounds/canceled-order-notification.mp3
Normal file
BIN
public/sounds/canceled-order-notification.mp3
Normal file
Binary file not shown.
BIN
public/sounds/new-order-notification.mp3
Normal file
BIN
public/sounds/new-order-notification.mp3
Normal file
Binary file not shown.
2
public/uploads/.gitkeep
Normal file
2
public/uploads/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file ensures the uploads directory is created for logo uploads
|
||||
|
||||
BIN
public/uploads/canceled-order-notification.mp3
Normal file
BIN
public/uploads/canceled-order-notification.mp3
Normal file
Binary file not shown.
BIN
public/uploads/new-order-notification.mp3
Normal file
BIN
public/uploads/new-order-notification.mp3
Normal file
Binary file not shown.
117
routes/auth.js
Normal file
117
routes/auth.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const config = require('../config');
|
||||
const apiClient = require('../api-client');
|
||||
|
||||
async function authRoutes(fastify, options) {
|
||||
|
||||
// Root route - redirect based on signed session cookie validity
|
||||
fastify.get('/', async (req, reply) => {
|
||||
try {
|
||||
const raw = req.cookies && req.cookies.kitchen_session;
|
||||
if (raw) {
|
||||
const { valid, value } = req.unsignCookie(raw || '');
|
||||
if (valid) {
|
||||
const token = config.get('authToken');
|
||||
const expiry = config.get('tokenExpiry');
|
||||
if (token && !apiClient.isTokenExpired(expiry) && value === token) {
|
||||
return reply.redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return reply.redirect('/login');
|
||||
});
|
||||
|
||||
// Login page
|
||||
fastify.get('/login', async (req, reply) => {
|
||||
try {
|
||||
const raw = req.cookies && req.cookies.kitchen_session;
|
||||
if (raw) {
|
||||
const { valid, value } = req.unsignCookie(raw || '');
|
||||
if (valid) {
|
||||
const token = config.get('authToken');
|
||||
const expiry = config.get('tokenExpiry');
|
||||
if (token && !apiClient.isTokenExpired(expiry) && value === token) {
|
||||
return reply.redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return reply.view('login', {
|
||||
error: req.query.error || null,
|
||||
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
|
||||
});
|
||||
});
|
||||
|
||||
// Login handler
|
||||
fastify.post('/auth/login', async (req, reply) => {
|
||||
const { login, password, 'g-recaptcha-response': recaptchaToken } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (!login || !password) {
|
||||
return reply.view('login', {
|
||||
error: 'Please provide email and password',
|
||||
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
|
||||
});
|
||||
}
|
||||
|
||||
if (!recaptchaToken) {
|
||||
return reply.view('login', {
|
||||
error: 'reCAPTCHA verification required',
|
||||
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Call API to authenticate
|
||||
const result = await apiClient.login(login, password, recaptchaToken);
|
||||
|
||||
if (result.error) {
|
||||
return reply.view('login', {
|
||||
error: result.message || 'Login failed',
|
||||
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
|
||||
});
|
||||
}
|
||||
|
||||
// Store auth token and expiration
|
||||
config.set('authToken', result.token);
|
||||
config.set('tokenExpiry', result.expirationDate);
|
||||
config.set('userEmail', login);
|
||||
|
||||
// Set signed cookie for session; secure only on HTTPS
|
||||
const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https');
|
||||
reply.setCookie('kitchen_session', result.token, {
|
||||
signed: true,
|
||||
httpOnly: true,
|
||||
secure: isHttps,
|
||||
sameSite: 'strict',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
path: '/'
|
||||
});
|
||||
|
||||
return reply.redirect('/dashboard');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error.message);
|
||||
return reply.view('login', {
|
||||
error: 'An error occurred during login. Please try again.',
|
||||
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Logout handler
|
||||
fastify.post('/auth/logout', async (req, reply) => {
|
||||
// Allow multiple sessions: only clear the browser cookie
|
||||
reply.clearCookie('kitchen_session');
|
||||
return reply.redirect('/login');
|
||||
});
|
||||
|
||||
fastify.get('/auth/logout', async (req, reply) => {
|
||||
// Allow multiple sessions: only clear the browser cookie
|
||||
reply.clearCookie('kitchen_session');
|
||||
return reply.redirect('/login');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = authRoutes;
|
||||
|
||||
35
routes/dashboard.js
Normal file
35
routes/dashboard.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const config = require('../config');
|
||||
const database = require('../database');
|
||||
|
||||
// Middleware to check authentication via signed cookie
|
||||
async function requireAuth(req, reply) {
|
||||
const raw = req.cookies && req.cookies.kitchen_session;
|
||||
if (!raw) { reply.redirect('/login'); return; }
|
||||
const { valid, value } = req.unsignCookie(raw || '');
|
||||
if (!valid) { reply.redirect('/login'); return; }
|
||||
const token = config.get('authToken');
|
||||
const expiry = config.get('tokenExpiry');
|
||||
const apiClient = require('../api-client');
|
||||
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
|
||||
reply.redirect('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function dashboardRoutes(fastify, options) {
|
||||
|
||||
// Dashboard page
|
||||
fastify.get('/dashboard', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const appConfig = config.getAll();
|
||||
const stats = database.getOrderStats();
|
||||
|
||||
return reply.view('dashboard', {
|
||||
config: appConfig,
|
||||
stats: stats,
|
||||
showStats: appConfig.showOrderStats !== 'false'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = dashboardRoutes;
|
||||
|
||||
343
routes/orders.js
Normal file
343
routes/orders.js
Normal file
@@ -0,0 +1,343 @@
|
||||
const config = require('../config');
|
||||
const database = require('../database');
|
||||
const apiClient = require('../api-client');
|
||||
const printer = require('../printer');
|
||||
|
||||
// Middleware to check authentication via signed cookie (JSON response)
|
||||
async function requireAuth(req, reply) {
|
||||
const raw = req.cookies && req.cookies.kitchen_session;
|
||||
if (!raw) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); }
|
||||
const { valid, value } = req.unsignCookie(raw || '');
|
||||
if (!valid) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); }
|
||||
const token = config.get('authToken');
|
||||
const expiry = config.get('tokenExpiry');
|
||||
const apiClient = require('../api-client');
|
||||
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
|
||||
return reply.code(401).send({ error: true, message: 'Not authenticated' });
|
||||
}
|
||||
}
|
||||
|
||||
async function ordersRoutes(fastify, options) {
|
||||
|
||||
// Get orders with filters
|
||||
fastify.get('/api/orders', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const filters = {
|
||||
status: req.query.status,
|
||||
limit: parseInt(req.query.limit, 10) || 50
|
||||
};
|
||||
|
||||
// Get today's date for stats
|
||||
const today = new Date();
|
||||
filters.date = today;
|
||||
|
||||
const orders = database.getOrders(filters);
|
||||
const stats = database.getOrderStats();
|
||||
|
||||
return { error: false, orders, stats };
|
||||
});
|
||||
|
||||
// Update order status
|
||||
fastify.post('/api/orders/:id/status', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const orderId = parseInt(req.params.id, 10);
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status) {
|
||||
return { error: true, message: 'Status is required' };
|
||||
}
|
||||
|
||||
// Valid local statuses
|
||||
const validStatuses = ['new', 'preparing', 'ready', 'completed'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return { error: true, message: 'Invalid status' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Update local database
|
||||
database.updateOrderStatus(orderId, status);
|
||||
|
||||
// Sync to backend if status is completed (maps to finished)
|
||||
if (status === 'completed') {
|
||||
const appConfig = config.getAll();
|
||||
const order = database.getOrderById(orderId);
|
||||
|
||||
if (order && appConfig.authToken && appConfig.selectedBotId) {
|
||||
// Determine the action based on order type
|
||||
let action = 'finished';
|
||||
if (order.order.type === 'delivery') {
|
||||
action = 'delivered';
|
||||
} else if (order.order.type === 'pickup') {
|
||||
action = 'picked_up';
|
||||
}
|
||||
|
||||
const result = await apiClient.modifyOrder(
|
||||
appConfig.authToken,
|
||||
appConfig.selectedBotId,
|
||||
orderId,
|
||||
action
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to sync order status to backend:', result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { error: false };
|
||||
} catch (error) {
|
||||
console.error('Failed to update order status:', error.message);
|
||||
return { error: true, message: 'Failed to update order status' };
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel order
|
||||
fastify.post('/api/orders/:id/cancel', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const orderId = parseInt(req.params.id, 10);
|
||||
const { reason } = req.body;
|
||||
|
||||
try {
|
||||
// Check if cancellation already printed to prevent duplicates
|
||||
if (database.hasPrintedCancellation(orderId)) {
|
||||
console.log(`[API] Cancellation already printed for order #${orderId}, skipping duplicate`);
|
||||
database.updateOrderStatus(orderId, 'canceled');
|
||||
return { error: false, message: 'Order already canceled' };
|
||||
}
|
||||
|
||||
// Update local database
|
||||
database.updateOrderStatus(orderId, 'canceled');
|
||||
|
||||
// Sync to backend
|
||||
const appConfig = config.getAll();
|
||||
if (appConfig.authToken && appConfig.selectedBotId) {
|
||||
const result = await apiClient.modifyOrder(
|
||||
appConfig.authToken,
|
||||
appConfig.selectedBotId,
|
||||
orderId,
|
||||
'cancel',
|
||||
reason || 'Canceled by kitchen'
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to sync cancellation to backend:', result.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Print cancellation receipt
|
||||
const order = database.getOrderById(orderId);
|
||||
if (order) {
|
||||
try {
|
||||
// Add to print queue with deduplication check
|
||||
const jobId = database.addToPrintQueue(orderId, 'canceled');
|
||||
if (!jobId) {
|
||||
console.log(`[API] Cancellation print job not created (duplicate prevention) for order #${orderId}`);
|
||||
return { error: false, message: 'Cancellation recorded' };
|
||||
}
|
||||
|
||||
database.markPrintJobProcessing(jobId);
|
||||
|
||||
// Get enabled printers from database
|
||||
const printerConfigs = database.getEnabledPrinters();
|
||||
let result;
|
||||
|
||||
if (printerConfigs && printerConfigs.length > 0) {
|
||||
// Use new per-printer config system
|
||||
result = await printer.printOrderReceiptWithPrinterConfigs(
|
||||
order,
|
||||
printerConfigs,
|
||||
'canceled',
|
||||
{ reason: reason || 'Canceled by kitchen' }
|
||||
);
|
||||
} else {
|
||||
// Fallback to legacy system if no printer configs
|
||||
if (!printer.printer) {
|
||||
printer.initializePrinter(appConfig);
|
||||
}
|
||||
result = await printer.printOrderReceipt(order, 'canceled', { reason: reason || 'Canceled by kitchen' });
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
database.markOrderPrinted(orderId);
|
||||
database.markPrintJobCompleted(jobId);
|
||||
// Cleanup any other pending jobs for this order+type
|
||||
database.cleanupDuplicateJobs(jobId, orderId, 'canceled');
|
||||
} else {
|
||||
// Mark as pending for worker retry
|
||||
database.markPrintJobPending(jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to print cancellation receipt:', error.message);
|
||||
// Let the worker retry - find the job and mark it pending
|
||||
try {
|
||||
const lastJobIdRow = database.db.prepare("SELECT id FROM print_queue WHERE order_id = ? AND print_type = 'canceled' ORDER BY id DESC LIMIT 1").get(orderId);
|
||||
if (lastJobIdRow && lastJobIdRow.id) { database.markPrintJobPending(lastJobIdRow.id); }
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
return { error: false };
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel order:', error.message);
|
||||
return { error: true, message: 'Failed to cancel order' };
|
||||
}
|
||||
});
|
||||
|
||||
// Reprint order
|
||||
fastify.post('/api/orders/:id/reprint', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const orderId = parseInt(req.params.id, 10);
|
||||
|
||||
try {
|
||||
const order = database.getOrderById(orderId);
|
||||
|
||||
if (!order) {
|
||||
return { error: true, message: 'Order not found' };
|
||||
}
|
||||
|
||||
const printType = order.localStatus === 'canceled' ? 'canceled' : 'new';
|
||||
|
||||
// Check for recent ACTIVE job to prevent double-enqueue while in-flight
|
||||
const activeCheck = database.hasActiveOrRecentJob(orderId, 'reprint', 10);
|
||||
if (activeCheck.hasActive && (activeCheck.status === 'pending' || activeCheck.status === 'processing')) {
|
||||
console.log(`[API] Reprint request for order #${orderId} blocked - active job ${activeCheck.jobId} (${activeCheck.status}) exists`);
|
||||
return { error: false, message: 'Print already in progress' };
|
||||
}
|
||||
|
||||
// Add to print queue with deduplication
|
||||
const jobId = database.addToPrintQueue(orderId, 'reprint');
|
||||
if (!jobId) {
|
||||
console.log(`[API] Reprint job not created (duplicate prevention) for order #${orderId}`);
|
||||
return { error: false, message: 'Print recently completed, skipping duplicate' };
|
||||
}
|
||||
|
||||
database.markPrintJobProcessing(jobId);
|
||||
|
||||
// Print receipt using per-printer configs
|
||||
const printerConfigs = database.getEnabledPrinters();
|
||||
|
||||
let result;
|
||||
if (printerConfigs && printerConfigs.length > 0) {
|
||||
// Use new per-printer config system
|
||||
result = await printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, printType, { cooldownMs: 2000 });
|
||||
} else {
|
||||
// Fallback to legacy system
|
||||
const appConfig = config.getAll();
|
||||
printer.initializePrinter(appConfig);
|
||||
result = await printer.printOrderReceipt(order, printType, { cooldownMs: 2000 });
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
database.markOrderPrinted(orderId);
|
||||
database.markPrintJobCompleted(jobId);
|
||||
// Cleanup any other pending jobs for this order+type
|
||||
database.cleanupDuplicateJobs(jobId, orderId, 'reprint');
|
||||
return { error: false, message: (result && result.message) ? result.message : 'Receipt sent to printer' };
|
||||
} else {
|
||||
// Mark as pending so the worker can retry when printer is online
|
||||
database.markPrintJobPending(jobId);
|
||||
return { error: true, message: result.error || 'Print failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reprint order:', error.message);
|
||||
try { database.resetStuckProcessingJobs(60); } catch (_) {}
|
||||
return { error: true, message: 'Failed to reprint order' };
|
||||
}
|
||||
});
|
||||
|
||||
// Get single order details
|
||||
fastify.get('/api/orders/:id', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const orderId = parseInt(req.params.id, 10);
|
||||
const order = database.getOrderById(orderId);
|
||||
|
||||
if (!order) {
|
||||
return { error: true, message: 'Order not found' };
|
||||
}
|
||||
|
||||
return { error: false, order };
|
||||
});
|
||||
|
||||
// Manual sync trigger (for testing/debugging)
|
||||
fastify.post('/api/sync-now', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
// Trigger the poller manually
|
||||
if (fastify.orderPoller) {
|
||||
fastify.orderPoller.poll();
|
||||
return { error: false, message: 'Manual sync triggered' };
|
||||
} else {
|
||||
return { error: true, message: 'Order poller not available' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Manual sync error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Health check for external API server
|
||||
fastify.get('/api/health/external', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const appConfig = config.getAll();
|
||||
const token = appConfig.authToken;
|
||||
const botId = appConfig.selectedBotId;
|
||||
|
||||
if (!token || !botId) {
|
||||
return {
|
||||
error: false,
|
||||
status: 'unconfigured',
|
||||
message: 'API not configured',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (apiClient.isTokenExpired(appConfig.tokenExpiry)) {
|
||||
return {
|
||||
error: false,
|
||||
status: 'offline',
|
||||
message: 'Token expired',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// Try to fetch bots list as a lightweight health check
|
||||
const startTime = Date.now();
|
||||
const result = await apiClient.getBots(token);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
error: false,
|
||||
status: 'offline',
|
||||
message: result.message || 'API server unreachable',
|
||||
responseTime: responseTime,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: false,
|
||||
status: 'online',
|
||||
message: 'API server connected',
|
||||
responseTime: responseTime,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('External API health check error:', error.message);
|
||||
return {
|
||||
error: false,
|
||||
status: 'offline',
|
||||
message: error.message || 'Health check failed',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Health check for local dashboard server
|
||||
fastify.get('/api/health/local', { preHandler: requireAuth }, async (req, reply) => {
|
||||
return {
|
||||
error: false,
|
||||
status: 'online',
|
||||
message: 'Local server connected',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = ordersRoutes;
|
||||
|
||||
459
routes/settings.js
Normal file
459
routes/settings.js
Normal file
@@ -0,0 +1,459 @@
|
||||
const config = require('../config');
|
||||
const database = require('../database');
|
||||
const apiClient = require('../api-client');
|
||||
const printer = require('../printer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { pipeline } = require('stream/promises');
|
||||
|
||||
// Middleware to check authentication via signed cookie
|
||||
async function requireAuth(req, reply) {
|
||||
const raw = req.cookies && req.cookies.kitchen_session;
|
||||
if (!raw) { reply.redirect('/login'); return; }
|
||||
const { valid, value } = req.unsignCookie(raw || '');
|
||||
if (!valid) { reply.redirect('/login'); return; }
|
||||
const token = config.get('authToken');
|
||||
const expiry = config.get('tokenExpiry');
|
||||
const apiClient = require('../api-client');
|
||||
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
|
||||
reply.redirect('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function settingsRoutes(fastify, options) {
|
||||
|
||||
// Settings page
|
||||
fastify.get('/settings', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const appConfig = config.getAll();
|
||||
|
||||
// Fetch available bots
|
||||
let bots = [];
|
||||
if (appConfig.authToken) {
|
||||
const botsResult = await apiClient.getBots(appConfig.authToken);
|
||||
if (!botsResult.error && botsResult.bots) {
|
||||
bots = botsResult.bots;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.view('settings', {
|
||||
config: appConfig,
|
||||
bots: bots,
|
||||
message: req.query.message || null,
|
||||
error: req.query.error || null
|
||||
});
|
||||
});
|
||||
|
||||
// Save settings
|
||||
fastify.post('/settings/save', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const {
|
||||
selectedBotId,
|
||||
pollingInterval,
|
||||
dashboardRefreshInterval,
|
||||
showOrderStats,
|
||||
soundNotificationsEnabled,
|
||||
soundVolume,
|
||||
printerType,
|
||||
printerInterface,
|
||||
printerPath,
|
||||
printerWidth,
|
||||
fontSize,
|
||||
lineStyle,
|
||||
qrCodeEnabled,
|
||||
qrCodeSize,
|
||||
qrCodeCorrection,
|
||||
qrCodeContentTemplate,
|
||||
headerText,
|
||||
footerText,
|
||||
businessName,
|
||||
businessAddress,
|
||||
businessPhone,
|
||||
businessWebsite,
|
||||
businessEmail,
|
||||
businessContactSize,
|
||||
showCustomerInfo,
|
||||
showOrderItems,
|
||||
showPrices,
|
||||
showTimestamps,
|
||||
selectedPrintersJson
|
||||
} = req.body;
|
||||
|
||||
// Validate and save configuration
|
||||
const configToSave = {};
|
||||
|
||||
if (selectedBotId) configToSave.selectedBotId = selectedBotId;
|
||||
if (pollingInterval) configToSave.pollingInterval = pollingInterval;
|
||||
if (dashboardRefreshInterval) configToSave.dashboardRefreshInterval = dashboardRefreshInterval;
|
||||
|
||||
configToSave.showOrderStats = showOrderStats === 'on' ? 'true' : 'false';
|
||||
|
||||
// Sound notification settings
|
||||
configToSave.soundNotificationsEnabled = soundNotificationsEnabled === 'on' ? 'true' : 'false';
|
||||
if (soundVolume !== undefined) configToSave.soundVolume = soundVolume;
|
||||
|
||||
if (printerType) configToSave.printerType = printerType;
|
||||
if (printerInterface) configToSave.printerInterface = printerInterface;
|
||||
if (printerPath) configToSave.printerPath = printerPath;
|
||||
if (printerWidth) configToSave.printerWidth = printerWidth;
|
||||
if (fontSize) configToSave.fontSize = fontSize;
|
||||
if (lineStyle) configToSave.lineStyle = lineStyle;
|
||||
|
||||
configToSave.qrCodeEnabled = qrCodeEnabled === 'on' ? 'true' : 'false';
|
||||
if (qrCodeSize) configToSave.qrCodeSize = parseInt(qrCodeSize, 10);
|
||||
if (qrCodeCorrection) configToSave.qrCodeCorrection = qrCodeCorrection;
|
||||
if (qrCodeContentTemplate !== undefined) configToSave.qrCodeContentTemplate = qrCodeContentTemplate;
|
||||
|
||||
if (headerText !== undefined) configToSave.headerText = headerText;
|
||||
if (footerText !== undefined) configToSave.footerText = footerText;
|
||||
if (businessName !== undefined) configToSave.businessName = businessName;
|
||||
if (businessAddress !== undefined) configToSave.businessAddress = businessAddress;
|
||||
if (businessPhone !== undefined) configToSave.businessPhone = businessPhone;
|
||||
if (businessWebsite !== undefined) configToSave.businessWebsite = businessWebsite;
|
||||
if (businessEmail !== undefined) configToSave.businessEmail = businessEmail;
|
||||
if (businessContactSize) configToSave.businessContactSize = businessContactSize;
|
||||
|
||||
configToSave.showCustomerInfo = showCustomerInfo === 'on' ? 'true' : 'false';
|
||||
configToSave.showOrderItems = showOrderItems === 'on' ? 'true' : 'false';
|
||||
configToSave.showPrices = showPrices === 'on' ? 'true' : 'false';
|
||||
configToSave.showTimestamps = showTimestamps === 'on' ? 'true' : 'false';
|
||||
|
||||
// Multi-printer selection (stored raw JSON string)
|
||||
// Smart sync: If multi-printer list is empty and main printer is configured,
|
||||
// auto-populate multi-printer with main printer to avoid confusion
|
||||
if (selectedPrintersJson !== undefined) {
|
||||
try {
|
||||
let parsed = JSON.parse(selectedPrintersJson || '[]');
|
||||
if (!Array.isArray(parsed)) parsed = [];
|
||||
|
||||
// If multi-printer list is empty but main printer is configured,
|
||||
// auto-add the main printer to the multi-printer list
|
||||
if (parsed.length === 0 && printerInterface && printerPath) {
|
||||
const mainType = printerInterface === 'serial' ? 'com' : printerInterface;
|
||||
parsed.push({
|
||||
type: mainType,
|
||||
interface: printerPath
|
||||
});
|
||||
console.log('[Settings] Auto-populated multi-printer list with main printer:', mainType, printerPath);
|
||||
}
|
||||
|
||||
configToSave.selectedPrintersJson = JSON.stringify(parsed);
|
||||
} catch (e) {
|
||||
console.warn('[Settings] Failed to process selectedPrintersJson:', e.message);
|
||||
// ignore malformed input; keep previous config
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
config.setMultiple(configToSave);
|
||||
|
||||
// Reinitialize printer with new config
|
||||
const updatedConfig = config.getAll();
|
||||
printer.initializePrinter(updatedConfig);
|
||||
|
||||
return reply.redirect('/settings?message=Settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error.message);
|
||||
return reply.redirect('/settings?error=Failed to save settings');
|
||||
}
|
||||
});
|
||||
|
||||
// Test printer
|
||||
fastify.post('/settings/test-printer', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const appConfig = config.getAll();
|
||||
|
||||
// Allow ad-hoc test using current unsaved selections from UI
|
||||
if (req.body && typeof req.body.selectedPrintersJson !== 'undefined') {
|
||||
try {
|
||||
const parsed = JSON.parse(req.body.selectedPrintersJson || '[]');
|
||||
if (Array.isArray(parsed)) {
|
||||
appConfig.selectedPrintersJson = JSON.stringify(parsed);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore bad input for test; fall back to saved config
|
||||
}
|
||||
}
|
||||
|
||||
// Always initialize to ensure latest selections are used
|
||||
printer.initializePrinter(appConfig);
|
||||
|
||||
const result = await printer.testPrint();
|
||||
|
||||
if (result.success) {
|
||||
return { error: false, message: 'Test print successful' };
|
||||
} else {
|
||||
return { error: true, message: result.error || 'Test print failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test print error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Detect available printers and COM ports (hardware detection)
|
||||
fastify.get('/api/printers/detect', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const list = await printer.getAvailablePrinters();
|
||||
return { error: false, printers: list };
|
||||
} catch (error) {
|
||||
console.error('List printers error:', error.message);
|
||||
return { error: true, message: error.message, printers: [] };
|
||||
}
|
||||
});
|
||||
|
||||
// Get all configured printers from database
|
||||
fastify.get('/api/printers/list', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printers = database.getAllPrinters();
|
||||
return { error: false, printers };
|
||||
} catch (error) {
|
||||
console.error('Get printers error:', error.message);
|
||||
return { error: true, message: error.message, printers: [] };
|
||||
}
|
||||
});
|
||||
|
||||
// Get single printer configuration
|
||||
fastify.get('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printerId = parseInt(req.params.id, 10);
|
||||
const printerConfig = database.getPrinter(printerId);
|
||||
if (!printerConfig) {
|
||||
return { error: true, message: 'Printer not found' };
|
||||
}
|
||||
return { error: false, printer: printerConfig };
|
||||
} catch (error) {
|
||||
console.error('Get printer error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Create new printer configuration
|
||||
fastify.post('/api/printers/create', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printerId = database.addPrinter(req.body);
|
||||
const newPrinter = database.getPrinter(printerId);
|
||||
return { error: false, message: 'Printer created successfully', printer: newPrinter };
|
||||
} catch (error) {
|
||||
console.error('Create printer error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Update printer configuration
|
||||
fastify.put('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printerId = parseInt(req.params.id, 10);
|
||||
database.updatePrinter(printerId, req.body);
|
||||
const updatedPrinter = database.getPrinter(printerId);
|
||||
return { error: false, message: 'Printer updated successfully', printer: updatedPrinter };
|
||||
} catch (error) {
|
||||
console.error('Update printer error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete printer configuration
|
||||
fastify.delete('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printerId = parseInt(req.params.id, 10);
|
||||
database.deletePrinter(printerId);
|
||||
return { error: false, message: 'Printer deleted successfully' };
|
||||
} catch (error) {
|
||||
console.error('Delete printer error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Set printer as default
|
||||
fastify.post('/api/printers/:id/set-default', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printerId = parseInt(req.params.id, 10);
|
||||
database.setDefaultPrinter(printerId);
|
||||
return { error: false, message: 'Default printer updated' };
|
||||
} catch (error) {
|
||||
console.error('Set default printer error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle printer enabled/disabled
|
||||
fastify.post('/api/printers/:id/toggle-enabled', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printerId = parseInt(req.params.id, 10);
|
||||
const result = database.togglePrinterEnabled(printerId);
|
||||
return { error: false, message: 'Printer status updated', is_enabled: result.is_enabled };
|
||||
} catch (error) {
|
||||
console.error('Toggle printer error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Test specific printer
|
||||
fastify.post('/api/printers/:id/test', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
const printerId = parseInt(req.params.id, 10);
|
||||
const printerConfig = database.getPrinter(printerId);
|
||||
|
||||
if (!printerConfig) {
|
||||
return { error: true, message: 'Printer not found' };
|
||||
}
|
||||
|
||||
const result = await printer.testPrintWithConfig(printerConfig);
|
||||
|
||||
if (result.success) {
|
||||
return { error: false, message: result.message || 'Test print successful' };
|
||||
} else {
|
||||
return { error: true, message: result.error || 'Test print failed' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test printer error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Upload logo - requires @fastify/multipart (now supports per-printer logos)
|
||||
fastify.post('/settings/upload-logo', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
// Get multipart data
|
||||
const data = await req.file();
|
||||
|
||||
if (!data) {
|
||||
return { error: true, message: 'No file uploaded' };
|
||||
}
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadsDir = path.join(__dirname, '..', 'public', 'uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Save file
|
||||
const filename = `logo-${Date.now()}${path.extname(data.filename)}`;
|
||||
const filepath = path.join(uploadsDir, filename);
|
||||
|
||||
// Write file using stream
|
||||
await pipeline(data.file, fs.createWriteStream(filepath));
|
||||
|
||||
// If printer_id is provided in fields, update that printer's logo
|
||||
// Otherwise update global config (backward compatibility)
|
||||
const fields = data.fields;
|
||||
const printerId = fields && fields.printer_id ? parseInt(fields.printer_id.value, 10) : null;
|
||||
|
||||
if (printerId) {
|
||||
// Update specific printer's logo
|
||||
const printerConfig = database.getPrinter(printerId);
|
||||
if (printerConfig) {
|
||||
database.updatePrinter(printerId, { ...printerConfig, logo_path: filepath });
|
||||
}
|
||||
} else {
|
||||
// Update global config (backward compatibility)
|
||||
config.set('logoPath', filepath);
|
||||
try {
|
||||
const updated = config.getAll();
|
||||
printer.initializePrinter(updated);
|
||||
} catch (e) {
|
||||
// proceed even if reinit fails
|
||||
}
|
||||
}
|
||||
|
||||
return { error: false, message: 'Logo uploaded successfully', path: `/public/uploads/${filename}`, filepath };
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get available bots
|
||||
fastify.get('/api/bots', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const appConfig = config.getAll();
|
||||
|
||||
if (!appConfig.authToken) {
|
||||
return { error: true, message: 'Not authenticated' };
|
||||
}
|
||||
|
||||
const result = await apiClient.getBots(appConfig.authToken);
|
||||
|
||||
if (result.error) {
|
||||
return { error: true, message: result.message };
|
||||
}
|
||||
|
||||
return { error: false, bots: result.bots || [] };
|
||||
});
|
||||
|
||||
// Get notification settings (for dashboard)
|
||||
fastify.get('/api/notification-settings', async (req, reply) => {
|
||||
try {
|
||||
const appConfig = config.getAll();
|
||||
return {
|
||||
error: false,
|
||||
soundNotificationsEnabled: appConfig.soundNotificationsEnabled || 'true',
|
||||
soundVolume: appConfig.soundVolume || '80',
|
||||
newOrderSoundPath: appConfig.newOrderSoundPath || '/public/sounds/new-order-notification.mp3',
|
||||
canceledOrderSoundPath: appConfig.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get notification settings:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Upload sound file for notifications
|
||||
fastify.post('/settings/upload-sound', { preHandler: requireAuth }, async (req, reply) => {
|
||||
try {
|
||||
// Get multipart data
|
||||
const data = await req.file();
|
||||
|
||||
if (!data) {
|
||||
return { error: true, message: 'No file uploaded' };
|
||||
}
|
||||
|
||||
// Ensure sounds directory exists
|
||||
const soundsDir = path.join(__dirname, '..', 'public', 'sounds');
|
||||
if (!fs.existsSync(soundsDir)) {
|
||||
fs.mkdirSync(soundsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validExtensions = ['.mp3', '.wav', '.ogg'];
|
||||
const fileExt = path.extname(data.filename).toLowerCase();
|
||||
|
||||
if (!validExtensions.includes(fileExt)) {
|
||||
return { error: true, message: 'Invalid file type. Please upload MP3, WAV, or OGG file.' };
|
||||
}
|
||||
|
||||
// Save file
|
||||
const filename = `notification-${Date.now()}${fileExt}`;
|
||||
const filepath = path.join(soundsDir, filename);
|
||||
|
||||
// Write file using stream
|
||||
await pipeline(data.file, fs.createWriteStream(filepath));
|
||||
|
||||
// Get the sound type from fields (newOrder or canceled)
|
||||
const fields = data.fields;
|
||||
const soundType = fields && fields.soundType ? fields.soundType.value : null;
|
||||
|
||||
const publicPath = `/public/sounds/${filename}`;
|
||||
|
||||
// Update config based on sound type
|
||||
if (soundType === 'newOrder') {
|
||||
config.set('newOrderSoundPath', publicPath);
|
||||
} else if (soundType === 'canceled') {
|
||||
config.set('canceledOrderSoundPath', publicPath);
|
||||
}
|
||||
|
||||
return {
|
||||
error: false,
|
||||
message: 'Sound uploaded successfully',
|
||||
path: publicPath,
|
||||
soundType: soundType
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Sound upload error:', error.message);
|
||||
return { error: true, message: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = settingsRoutes;
|
||||
|
||||
442
server.js
Normal file
442
server.js
Normal file
@@ -0,0 +1,442 @@
|
||||
// Load environment variables
|
||||
require('dotenv').config();
|
||||
const { checkAndUpdate } = require('./updater');
|
||||
|
||||
const Fastify = require('fastify');
|
||||
const path = require('path');
|
||||
const database = require('./database');
|
||||
const config = require('./config');
|
||||
const apiClient = require('./api-client');
|
||||
const printer = require('./printer');
|
||||
const PrintQueueWorker = require('./print-worker');
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: true
|
||||
});
|
||||
|
||||
const isDev = false;
|
||||
|
||||
// Order Poller Class
|
||||
class OrderPoller {
|
||||
constructor(apiClient, database, printer) {
|
||||
this.apiClient = apiClient;
|
||||
this.db = database;
|
||||
this.printer = printer;
|
||||
this.intervalId = null;
|
||||
this.isPolling = false;
|
||||
}
|
||||
|
||||
async start() {
|
||||
console.log('Starting order poller...');
|
||||
|
||||
// Initial poll
|
||||
this.poll();
|
||||
|
||||
// Schedule recurring polls
|
||||
this.scheduleNextPoll();
|
||||
}
|
||||
|
||||
scheduleNextPoll() {
|
||||
const appConfig = config.getAll();
|
||||
const interval = parseInt(appConfig.pollingInterval, 10) || 15000;
|
||||
|
||||
if (this.intervalId) {
|
||||
clearTimeout(this.intervalId);
|
||||
}
|
||||
|
||||
this.intervalId = setTimeout(() => {
|
||||
this.poll();
|
||||
this.scheduleNextPoll();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
async poll() {
|
||||
if (this.isPolling) {
|
||||
console.log('Poll already in progress, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPolling = true;
|
||||
|
||||
try {
|
||||
const appConfig = config.getAll();
|
||||
|
||||
// Check if configured
|
||||
if (!appConfig.authToken || !appConfig.selectedBotId) {
|
||||
console.log('Not configured yet, skipping poll');
|
||||
this.isPolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (apiClient.isTokenExpired(appConfig.tokenExpiry)) {
|
||||
console.log('Token expired, please re-login');
|
||||
this.isPolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get last synced order ID
|
||||
const lastOrder = this.db.getLastOrder();
|
||||
const afterId = lastOrder ? lastOrder.order_id : 0;
|
||||
|
||||
console.log(`Polling for new orders (afterId: ${afterId})...`);
|
||||
|
||||
// Fetch new orders from API
|
||||
const result = await this.apiClient.getOrders(
|
||||
appConfig.authToken,
|
||||
appConfig.selectedBotId,
|
||||
afterId,
|
||||
{ includeCanceled: true, limit: 50 }
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to fetch orders:', result.message);
|
||||
this.isPolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const orders = result.orders || [];
|
||||
console.log(`Received ${orders.length} orders from API`);
|
||||
|
||||
// Process each order
|
||||
for (const order of orders) {
|
||||
const existingOrder = this.db.getOrderById(order.id);
|
||||
|
||||
if (!existingOrder) {
|
||||
// New order - save and print
|
||||
console.log(`New order detected: #${order.id}`);
|
||||
this.db.insertOrder(order);
|
||||
|
||||
// Initialize printer if needed
|
||||
if (!this.printer.printer) {
|
||||
this.printer.initializePrinter(appConfig);
|
||||
}
|
||||
|
||||
// Add to print queue with deduplication check
|
||||
const newJobId = this.db.addToPrintQueue(order.id, 'new');
|
||||
if (!newJobId) {
|
||||
console.log(`Print job not created for order #${order.id} (duplicate prevention)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.db.markPrintJobProcessing(newJobId);
|
||||
|
||||
try {
|
||||
// Use per-printer config system
|
||||
const printerConfigs = this.db.getEnabledPrinters();
|
||||
let result;
|
||||
|
||||
if (printerConfigs && printerConfigs.length > 0) {
|
||||
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'new');
|
||||
} else {
|
||||
// Fallback to legacy system
|
||||
if (!this.printer.printer || !this.printer.config) {
|
||||
const currentConfig = require('./config').getAll();
|
||||
this.printer.initializePrinter(currentConfig);
|
||||
}
|
||||
result = await this.printer.printOrderReceipt(order, 'new');
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
this.db.markOrderPrinted(order.id);
|
||||
this.db.markPrintJobCompleted(newJobId);
|
||||
// Cleanup any other pending jobs for this order+type
|
||||
this.db.cleanupDuplicateJobs(newJobId, order.id, 'new');
|
||||
console.log(`✓ Receipt printed for order #${order.id}`);
|
||||
} else {
|
||||
console.error(`✗ Print result indicates failure for order #${order.id}:`, result && result.error ? result.error : 'Unknown error');
|
||||
this.db.markPrintJobPending(newJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to print order #${order.id}:`, error.message);
|
||||
console.log(' Order saved to database. You can reprint from dashboard.');
|
||||
this.db.markPrintJobPending(newJobId);
|
||||
}
|
||||
} else {
|
||||
// Check for status changes (cancellation status from backend)
|
||||
const statusChanged = existingOrder.status !== order.status;
|
||||
|
||||
if (statusChanged) {
|
||||
console.log(`Order #${order.id} status changed: ${existingOrder.status} → ${order.status}`);
|
||||
|
||||
this.db.updateOrder(order);
|
||||
|
||||
// Print cancellation receipt if order was canceled
|
||||
if (order.status === 'canceled' && existingOrder.status !== 'canceled') {
|
||||
console.log(`Order #${order.id} was canceled - printing cancellation receipt...`);
|
||||
|
||||
// Update local status to 'canceled' so it shows on the dashboard
|
||||
this.db.updateOrderStatus(order.id, 'canceled');
|
||||
|
||||
// Check if cancellation already printed to prevent duplicates
|
||||
if (this.db.hasPrintedCancellation(order.id)) {
|
||||
console.log(`Skipping duplicate cancellation print for order #${order.id}`);
|
||||
} else {
|
||||
// Add to print queue with deduplication check
|
||||
const jobId = this.db.addToPrintQueue(order.id, 'canceled');
|
||||
if (!jobId) {
|
||||
console.log(`Cancellation job not created for order #${order.id} (duplicate prevention)`);
|
||||
} else {
|
||||
this.db.markPrintJobProcessing(jobId);
|
||||
|
||||
try {
|
||||
const cancelReason = order.cancellationReason || order.order?.cancellationReason || 'Order canceled';
|
||||
|
||||
// Use per-printer config system
|
||||
const printerConfigs = this.db.getEnabledPrinters();
|
||||
let result;
|
||||
|
||||
if (printerConfigs && printerConfigs.length > 0) {
|
||||
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'canceled', { reason: cancelReason });
|
||||
} else {
|
||||
// Fallback to legacy system
|
||||
if (!this.printer.printer || !this.printer.config) {
|
||||
const currentConfig = require('./config').getAll();
|
||||
this.printer.initializePrinter(currentConfig);
|
||||
}
|
||||
result = await this.printer.printOrderReceipt(order, 'canceled', { reason: cancelReason });
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
this.db.markOrderPrinted(order.id);
|
||||
this.db.markPrintJobCompleted(jobId);
|
||||
// Cleanup any other pending jobs for this order+type
|
||||
this.db.cleanupDuplicateJobs(jobId, order.id, 'canceled');
|
||||
console.log(`✓ Cancellation receipt printed for order #${order.id}`);
|
||||
} else {
|
||||
console.error(`✗ Cancellation print failed for order #${order.id}:`, result && result.error ? result.error : 'Unknown error');
|
||||
this.db.markPrintJobPending(jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to print cancellation for order #${order.id}:`, error.message);
|
||||
console.log(' Order updated in database. You can reprint from dashboard.');
|
||||
this.db.markPrintJobPending(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconciliation fetch: scan recent orders window to catch updates (e.g., cancellations)
|
||||
try {
|
||||
const recentResult = await this.apiClient.getOrders(
|
||||
appConfig.authToken,
|
||||
appConfig.selectedBotId,
|
||||
0,
|
||||
{ includeCanceled: true, limit: 200 }
|
||||
);
|
||||
|
||||
if (!recentResult.error) {
|
||||
const recentOrders = recentResult.orders || [];
|
||||
for (const order of recentOrders) {
|
||||
const existingOrder = this.db.getOrderById(order.id);
|
||||
if (!existingOrder) {
|
||||
// Skip new orders here; they are handled by the main afterId fetch
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for status changes (cancellation status from backend)
|
||||
const statusChanged = existingOrder.status !== order.status;
|
||||
|
||||
if (statusChanged) {
|
||||
console.log(`(reconcile) Order #${order.id} status changed: ${existingOrder.status} → ${order.status}`);
|
||||
|
||||
this.db.updateOrder(order);
|
||||
|
||||
// Print cancellation receipt if order was canceled
|
||||
if (order.status === 'canceled' && existingOrder.status !== 'canceled') {
|
||||
console.log(`(reconcile) Order #${order.id} was canceled - printing cancellation receipt...`);
|
||||
|
||||
// Update local status to 'canceled' so it shows on the dashboard
|
||||
this.db.updateOrderStatus(order.id, 'canceled');
|
||||
|
||||
// Check if cancellation already printed to prevent duplicates
|
||||
if (this.db.hasPrintedCancellation(order.id)) {
|
||||
console.log(`(reconcile) Skipping duplicate cancellation print for order #${order.id}`);
|
||||
} else {
|
||||
// Add to print queue with deduplication check
|
||||
const jobId = this.db.addToPrintQueue(order.id, 'canceled');
|
||||
if (!jobId) {
|
||||
console.log(`(reconcile) Cancellation job not created for order #${order.id} (duplicate prevention)`);
|
||||
} else {
|
||||
this.db.markPrintJobProcessing(jobId);
|
||||
|
||||
try {
|
||||
const cancelReason = order.cancellationReason || order.order?.cancellationReason || 'Order canceled';
|
||||
|
||||
// Use per-printer config system
|
||||
const printerConfigs = this.db.getEnabledPrinters();
|
||||
let result;
|
||||
|
||||
if (printerConfigs && printerConfigs.length > 0) {
|
||||
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'canceled', { reason: cancelReason });
|
||||
} else {
|
||||
// Fallback to legacy system
|
||||
if (!this.printer.printer || !this.printer.config) {
|
||||
const currentConfig = require('./config').getAll();
|
||||
this.printer.initializePrinter(currentConfig);
|
||||
}
|
||||
result = await this.printer.printOrderReceipt(order, 'canceled', { reason: cancelReason });
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
this.db.markOrderPrinted(order.id);
|
||||
this.db.markPrintJobCompleted(jobId);
|
||||
// Cleanup any other pending jobs for this order+type
|
||||
this.db.cleanupDuplicateJobs(jobId, order.id, 'canceled');
|
||||
console.log(`✓ Cancellation receipt printed for order #${order.id}`);
|
||||
} else {
|
||||
console.error(`✗ Cancellation print failed for order #${order.id}:`, result && result.error ? result.error : 'Unknown error');
|
||||
this.db.markPrintJobPending(jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to print cancellation for order #${order.id}:`, error.message);
|
||||
console.log(' Order updated in database. You can reprint from dashboard.');
|
||||
this.db.markPrintJobPending(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (reconcileErr) {
|
||||
console.error('Reconciliation fetch error:', reconcileErr.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Poll error:', error.message);
|
||||
}
|
||||
|
||||
this.isPolling = false;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearTimeout(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
console.log('Order poller stopped');
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.stop();
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Main initialization
|
||||
async function main() {
|
||||
try {
|
||||
// Initialize database
|
||||
database.init();
|
||||
console.log('Database initialized');
|
||||
|
||||
// Register plugins
|
||||
await fastify.register(require('@fastify/view'), {
|
||||
engine: {
|
||||
ejs: require('ejs')
|
||||
},
|
||||
root: path.join(__dirname, 'views')
|
||||
});
|
||||
|
||||
await fastify.register(require('@fastify/static'), {
|
||||
root: path.join(__dirname, 'public'),
|
||||
prefix: '/public/'
|
||||
});
|
||||
|
||||
await fastify.register(require('@fastify/cookie'), {
|
||||
secret: process.env.COOKIE_SECRET || 'kitchen-agent-secret-key-change-in-production',
|
||||
hook: 'onRequest'
|
||||
});
|
||||
|
||||
await fastify.register(require('@fastify/formbody'));
|
||||
|
||||
await fastify.register(require('@fastify/multipart'), {
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit for logo uploads
|
||||
}
|
||||
});
|
||||
|
||||
// Register routes
|
||||
await fastify.register(require('./routes/auth'));
|
||||
await fastify.register(require('./routes/dashboard'));
|
||||
await fastify.register(require('./routes/settings'));
|
||||
await fastify.register(require('./routes/orders'));
|
||||
|
||||
// Initialize printer with config
|
||||
const appConfig = config.getAll();
|
||||
if (appConfig.printerType && appConfig.printerPath) {
|
||||
try {
|
||||
printer.initializePrinter(appConfig);
|
||||
console.log('Printer initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize printer:', error.message);
|
||||
console.log('Printer can be configured later in Settings');
|
||||
}
|
||||
} else {
|
||||
console.log('Printer not configured - configure in Settings');
|
||||
}
|
||||
|
||||
// Start order poller
|
||||
const poller = new OrderPoller(apiClient, database, printer);
|
||||
const printWorker = new PrintQueueWorker(database, config, printer);
|
||||
|
||||
// Make poller available globally for restart after settings change
|
||||
fastify.decorate('orderPoller', poller);
|
||||
fastify.decorate('printWorker', printWorker);
|
||||
|
||||
// Start server
|
||||
const port = parseInt(process.env.PORT, 10) || 3000;
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
await fastify.listen({ port, host });
|
||||
|
||||
const addresses = fastify.server.address();
|
||||
console.log('\n=================================================');
|
||||
console.log('Think Link AI Kitchen Agent is running!');
|
||||
console.log(`Access at: http://localhost:${port}`);
|
||||
console.log(`Or use your computer's IP address from other devices`);
|
||||
console.log('=================================================\n');
|
||||
|
||||
// Kick off auto-update check (at boot and on interval)
|
||||
if (isDev) {
|
||||
console.log('Dev mode detected: skipping auto-update checks');
|
||||
} else {
|
||||
try { checkAndUpdate(); } catch (_) {}
|
||||
const envIntervalRaw = process.env.UPDATE_CHECK_INTERVAL_MS;
|
||||
const envIntervalMs = envIntervalRaw ? parseInt(envIntervalRaw, 10) : NaN;
|
||||
const updateIntervalMs = (!Number.isNaN(envIntervalMs) && envIntervalMs > 0) ? envIntervalMs : (5 * 60 * 1000);
|
||||
setInterval(() => { try { checkAndUpdate(); } catch (e) { fastify.log.error(e); } }, updateIntervalMs);
|
||||
}
|
||||
|
||||
// Start polling after server is up
|
||||
poller.start();
|
||||
// Start print queue worker
|
||||
printWorker.start();
|
||||
|
||||
// Handle shutdown gracefully (PM2 reload-friendly)
|
||||
const gracefulShutdown = async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
poller.stop();
|
||||
try { printWorker.stop(); } catch (_) {}
|
||||
try { database.close(); } catch (_) {}
|
||||
try { await fastify.close(); } catch (_) {}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', gracefulShutdown);
|
||||
process.on('message', (msg) => { if (msg === 'shutdown') gracefulShutdown(); });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the application
|
||||
main();
|
||||
|
||||
128
test-startup.js
Normal file
128
test-startup.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Kitchen Agent - Startup Test
|
||||
* Tests the complete server startup and shutdown
|
||||
*/
|
||||
|
||||
console.log('Testing Kitchen Agent startup...\n');
|
||||
|
||||
// Load environment
|
||||
require('dotenv').config();
|
||||
|
||||
async function testStartup() {
|
||||
let server = null;
|
||||
|
||||
try {
|
||||
console.log('Step 1: Initializing database...');
|
||||
const database = require('./database');
|
||||
database.init();
|
||||
console.log(' ✓ Database ready\n');
|
||||
|
||||
console.log('Step 2: Loading modules...');
|
||||
const config = require('./config');
|
||||
const apiClient = require('./api-client');
|
||||
const printer = require('./printer');
|
||||
console.log(' ✓ All modules loaded\n');
|
||||
|
||||
console.log('Step 3: Starting Fastify server...');
|
||||
const Fastify = require('fastify');
|
||||
const path = require('path');
|
||||
|
||||
const fastify = Fastify({ logger: false });
|
||||
|
||||
// Register plugins
|
||||
await fastify.register(require('@fastify/view'), {
|
||||
engine: { ejs: require('ejs') },
|
||||
root: path.join(__dirname, 'views')
|
||||
});
|
||||
|
||||
await fastify.register(require('@fastify/static'), {
|
||||
root: path.join(__dirname, 'public'),
|
||||
prefix: '/public/'
|
||||
});
|
||||
|
||||
await fastify.register(require('@fastify/cookie'), {
|
||||
secret: process.env.COOKIE_SECRET || 'test-secret'
|
||||
});
|
||||
|
||||
await fastify.register(require('@fastify/formbody'));
|
||||
|
||||
await fastify.register(require('@fastify/multipart'), {
|
||||
limits: { fileSize: 5 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
console.log(' ✓ Plugins registered\n');
|
||||
|
||||
console.log('Step 4: Registering routes...');
|
||||
await fastify.register(require('./routes/auth'));
|
||||
await fastify.register(require('./routes/dashboard'));
|
||||
await fastify.register(require('./routes/settings'));
|
||||
await fastify.register(require('./routes/orders'));
|
||||
console.log(' ✓ All routes registered\n');
|
||||
|
||||
console.log('Step 5: Starting server on port 3000...');
|
||||
await fastify.listen({ port: 3000, host: '127.0.0.1' });
|
||||
console.log(' ✓ Server started successfully\n');
|
||||
|
||||
server = fastify;
|
||||
|
||||
console.log('Step 6: Testing routes...');
|
||||
|
||||
// Test root route
|
||||
const response = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
if (response.statusCode === 302) {
|
||||
console.log(' ✓ Root route works (redirects)');
|
||||
} else {
|
||||
console.log(` ✗ Root route returned ${response.statusCode}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Test login page
|
||||
const loginResponse = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/login'
|
||||
});
|
||||
|
||||
if (loginResponse.statusCode === 200) {
|
||||
console.log(' ✓ Login page loads');
|
||||
} else {
|
||||
console.log(` ✗ Login page returned ${loginResponse.statusCode}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
console.log('\nStep 7: Shutting down server...');
|
||||
await fastify.close();
|
||||
console.log(' ✓ Server stopped gracefully\n');
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('✅ STARTUP TEST PASSED');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\nThe Kitchen Agent is ready to run!');
|
||||
console.log('Start with: npm start\n');
|
||||
|
||||
database.close();
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ STARTUP TEST FAILED');
|
||||
console.error('Error:', error.message);
|
||||
console.error('\nStack trace:');
|
||||
console.error(error.stack);
|
||||
|
||||
if (server) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
testStartup();
|
||||
|
||||
184
test-utils.js
Normal file
184
test-utils.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Test utilities for Kitchen Agent
|
||||
* Use these functions to manually test the system
|
||||
*/
|
||||
|
||||
const database = require('./database');
|
||||
|
||||
// Initialize database first
|
||||
database.init();
|
||||
|
||||
/**
|
||||
* Insert a mock order for testing UI
|
||||
*/
|
||||
function insertMockOrder(orderId = null) {
|
||||
const id = orderId || Math.floor(Math.random() * 10000);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const mockOrder = {
|
||||
id: id,
|
||||
botId: 1,
|
||||
orderStatus: 'new',
|
||||
order: {
|
||||
type: Math.random() > 0.5 ? 'delivery' : 'pickup',
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
itemName: 'Cheeseburger',
|
||||
qty: 2,
|
||||
price: 12.99,
|
||||
addons: [{ name: 'Extra Cheese', price: 1.00 }],
|
||||
exclude: [{ name: 'Onions', price: 0 }]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
itemName: 'French Fries',
|
||||
qty: 1,
|
||||
price: 4.99,
|
||||
addons: [],
|
||||
exclude: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
itemName: 'Coca-Cola',
|
||||
qty: 2,
|
||||
price: 2.50,
|
||||
addons: [],
|
||||
exclude: []
|
||||
}
|
||||
],
|
||||
amount: 35.97,
|
||||
taxRate: 8.5,
|
||||
taxAmount: 3.06,
|
||||
deliveryFee: 5.00,
|
||||
totalAmount: 44.03,
|
||||
deliveryAddress: '123 Main Street, Anytown, USA 12345',
|
||||
deliveryInstructions: 'Ring doorbell, leave at door',
|
||||
specialInstructions: 'Extra napkins please, no ketchup',
|
||||
foodAllergy: Math.random() > 0.7,
|
||||
foodAllergyNotes: 'Severe peanut allergy - please ensure no cross-contamination'
|
||||
},
|
||||
customer: {
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
phoneNumber: '+15551234567',
|
||||
email: 'john.doe@example.com'
|
||||
},
|
||||
totalAmount: 44.03,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
try {
|
||||
database.insertOrder(mockOrder);
|
||||
console.log(`✓ Mock order #${id} inserted successfully`);
|
||||
return mockOrder;
|
||||
} catch (error) {
|
||||
console.error('Failed to insert mock order:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert multiple mock orders
|
||||
*/
|
||||
function insertMultipleMockOrders(count = 5) {
|
||||
console.log(`Inserting ${count} mock orders...`);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const baseId = 10000 + i;
|
||||
insertMockOrder(baseId);
|
||||
}
|
||||
|
||||
console.log(`✓ ${count} mock orders inserted`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all orders from database (for testing)
|
||||
*/
|
||||
function clearAllOrders() {
|
||||
try {
|
||||
database.db.exec('DELETE FROM orders');
|
||||
database.db.exec('DELETE FROM print_queue');
|
||||
console.log('✓ All orders cleared');
|
||||
} catch (error) {
|
||||
console.error('Failed to clear orders:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View current configuration
|
||||
*/
|
||||
function viewConfig() {
|
||||
const config = database.getConfig();
|
||||
console.log('\nCurrent Configuration:');
|
||||
console.log('=====================');
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === 'authToken' && value) {
|
||||
console.log(`${key}: [ENCRYPTED]`);
|
||||
} else {
|
||||
console.log(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
console.log('=====================\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* View order statistics
|
||||
*/
|
||||
function viewStats() {
|
||||
const stats = database.getOrderStats();
|
||||
console.log('\nOrder Statistics:');
|
||||
console.log('=================');
|
||||
console.log(`Total Today: ${stats.total}`);
|
||||
console.log(`New: ${stats.new}`);
|
||||
console.log(`Preparing: ${stats.preparing}`);
|
||||
console.log(`Ready: ${stats.ready}`);
|
||||
console.log('=================\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* List all orders
|
||||
*/
|
||||
function listOrders(limit = 10) {
|
||||
const orders = database.getOrders({ limit });
|
||||
console.log(`\nRecent Orders (${orders.length}):`);
|
||||
console.log('===================');
|
||||
|
||||
orders.forEach(order => {
|
||||
console.log(`#${order.id} | ${order.customer.name} | ${order.localStatus} | $${order.totalAmount.toFixed(2)}`);
|
||||
});
|
||||
|
||||
console.log('===================\n');
|
||||
}
|
||||
|
||||
// Export functions
|
||||
module.exports = {
|
||||
insertMockOrder,
|
||||
insertMultipleMockOrders,
|
||||
clearAllOrders,
|
||||
viewConfig,
|
||||
viewStats,
|
||||
listOrders
|
||||
};
|
||||
|
||||
// If run directly, show help
|
||||
if (require.main === module) {
|
||||
console.log('\nKitchen Agent Test Utilities');
|
||||
console.log('============================\n');
|
||||
console.log('Usage:');
|
||||
console.log(' node test-utils.js\n');
|
||||
console.log('Available in Node REPL:');
|
||||
console.log(' const test = require("./test-utils");\n');
|
||||
console.log(' test.insertMockOrder(); // Insert one mock order');
|
||||
console.log(' test.insertMultipleMockOrders(5); // Insert 5 mock orders');
|
||||
console.log(' test.listOrders(); // List recent orders');
|
||||
console.log(' test.viewStats(); // View order statistics');
|
||||
console.log(' test.viewConfig(); // View configuration');
|
||||
console.log(' test.clearAllOrders(); // Clear all orders (careful!)\n');
|
||||
|
||||
// Show current stats
|
||||
viewStats();
|
||||
listOrders(5);
|
||||
}
|
||||
|
||||
94
updater.js
Normal file
94
updater.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
const {
|
||||
GITEA_TOKEN,
|
||||
REPO_URL = 'https://repo.cloud.thinklink.ai/thinklink/kitchen-agent.git',
|
||||
REPO_BRANCH = 'main',
|
||||
PM2_APP = 'kitchen-agent'
|
||||
} = process.env;
|
||||
|
||||
const repositoryNormalized = REPO_URL.endsWith('.git') ? REPO_URL : `${REPO_URL}.git`;
|
||||
|
||||
const updaterOptions = {
|
||||
repository: repositoryNormalized,
|
||||
branch: REPO_BRANCH,
|
||||
token: GITEA_TOKEN,
|
||||
// place updates in OS temp dir to avoid permission/self-copy issues
|
||||
tempLocation: path.join(os.tmpdir(), 'kitchen-agent-updates'),
|
||||
// only include files that actually exist in the upstream repo; module uses unlinkSync
|
||||
ignoreFiles: ['.env'],
|
||||
executeOnComplete: process.platform === 'win32'
|
||||
? `IF EXIST package-lock.json (npm ci --omit=dev) ELSE (npm install --omit=dev) & pm2 reload ${PM2_APP}`
|
||||
: `[ -f package-lock.json ] && npm ci --omit=dev || npm install --omit=dev; pm2 reload ${PM2_APP}`,
|
||||
exitOnComplete: false
|
||||
};
|
||||
|
||||
let updaterInstance = null;
|
||||
|
||||
async function getUpdater() {
|
||||
if (updaterInstance) { return updaterInstance; }
|
||||
const mod = await import('auto-git-update');
|
||||
const AutoGitUpdate = mod && mod.default ? mod.default : mod;
|
||||
updaterInstance = new AutoGitUpdate(updaterOptions);
|
||||
return updaterInstance;
|
||||
}
|
||||
|
||||
async function checkAndUpdate() {
|
||||
try {
|
||||
const updater = await getUpdater();
|
||||
|
||||
const isGithub = /github\.com/i.test(repositoryNormalized);
|
||||
let needsUpdate = false;
|
||||
|
||||
if (!isGithub) {
|
||||
// Use Gitea-aware version check to avoid library's GitHub-only path
|
||||
const localVersion = (() => {
|
||||
try { return require('./package.json').version || null; } catch (_) { return null; }
|
||||
})();
|
||||
const base = repositoryNormalized.replace(/\.git$/i, '');
|
||||
const rawUrl = `${base}/raw/branch/${REPO_BRANCH}/package.json`;
|
||||
|
||||
async function tryFetch(url) {
|
||||
const headers = GITEA_TOKEN ? { Authorization: `token ${GITEA_TOKEN}` } : undefined;
|
||||
const r = await fetch(url, { headers });
|
||||
if (r.ok) return r.json();
|
||||
return null;
|
||||
}
|
||||
|
||||
let remotePkg = await tryFetch(rawUrl);
|
||||
if (!remotePkg && GITEA_TOKEN) {
|
||||
remotePkg = (await tryFetch(`${rawUrl}?token=${GITEA_TOKEN}`))
|
||||
|| (await tryFetch(`${rawUrl}?access_token=${GITEA_TOKEN}`));
|
||||
}
|
||||
|
||||
const remoteVersion = remotePkg && remotePkg.version ? String(remotePkg.version) : null;
|
||||
if (!remoteVersion) {
|
||||
console.error('[auto-update] Unable to read remote version from Gitea raw endpoint');
|
||||
} else if (!localVersion || remoteVersion !== String(localVersion)) {
|
||||
console.log(`[auto-update] Update available: ${localVersion || 'unknown'} -> ${remoteVersion}`);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await updater.forceUpdate();
|
||||
} else {
|
||||
console.log('[auto-update] Up to date');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// GitHub-based repos: use library's compareVersions, then forceUpdate to avoid double compare
|
||||
const res = await updater.compareVersions();
|
||||
if (res && res.upToDate === false) {
|
||||
await updater.forceUpdate();
|
||||
} else {
|
||||
console.log('[auto-update] Up to date');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[auto-update] failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkAndUpdate };
|
||||
145
views/dashboard.ejs
Normal file
145
views/dashboard.ejs
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kitchen Display - Kitchen Agent</title>
|
||||
<link rel="stylesheet" href="/public/css/style.css">
|
||||
</head>
|
||||
<body class="dashboard-page">
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<h1>Kitchen Display</h1>
|
||||
<div class="connection-status-bar" id="connectionStatusBar">
|
||||
<div class="connection-status-item" id="localConnectionStatus" title="Connection to local dashboard server">
|
||||
<span class="status-indicator status-checking"></span>
|
||||
<span class="status-text">Dashboard</span>
|
||||
<span class="status-label" id="localStatusLabel">Checking...</span>
|
||||
</div>
|
||||
<div class="connection-status-item" id="apiConnectionStatus" title="Connection to api.thinklink.ai">
|
||||
<span class="status-indicator status-checking"></span>
|
||||
<span class="status-text">API Server</span>
|
||||
<span class="status-label" id="apiStatusLabel">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="/settings" class="btn btn-secondary">Settings</a>
|
||||
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if (showStats) { %>
|
||||
<div class="stats-bar" id="statsBar">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Today</div>
|
||||
<div class="stat-value" id="stat-total"><%= stats.total %></div>
|
||||
</div>
|
||||
<div class="stat-card stat-new">
|
||||
<div class="stat-label">New</div>
|
||||
<div class="stat-value" id="stat-new"><%= stats.new %></div>
|
||||
</div>
|
||||
<div class="stat-card stat-preparing">
|
||||
<div class="stat-label">Preparing</div>
|
||||
<div class="stat-value" id="stat-preparing"><%= stats.preparing %></div>
|
||||
</div>
|
||||
<div class="stat-card stat-ready">
|
||||
<div class="stat-label">Ready</div>
|
||||
<div class="stat-value" id="stat-ready"><%= stats.ready %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="dashboard-controls">
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">All Orders</button>
|
||||
<button class="filter-btn" data-filter="new">New</button>
|
||||
<button class="filter-btn" data-filter="preparing">Preparing</button>
|
||||
<button class="filter-btn" data-filter="ready">Ready</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary sync-button" id="syncButton" onclick="manualSync()" title="Check for new orders now">
|
||||
<span class="sync-spinner"></span>
|
||||
<span class="sync-text">Sync Now</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="orders-container" id="ordersContainer">
|
||||
<div class="loading">Loading orders...</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Change Confirmation Modal -->
|
||||
<div class="modal" id="statusChangeModal">
|
||||
<div class="modal-content status-modal-content">
|
||||
<div class="status-modal-icon" id="statusModalIcon">
|
||||
<!-- Icon will be set dynamically -->
|
||||
</div>
|
||||
<h2 id="statusModalTitle">Confirm Status Change</h2>
|
||||
<div class="status-modal-details">
|
||||
<div class="status-modal-row">
|
||||
<span class="status-modal-label">Order #:</span>
|
||||
<span class="status-modal-value" id="statusModalOrderId">-</span>
|
||||
</div>
|
||||
<div class="status-modal-row">
|
||||
<span class="status-modal-label">Customer:</span>
|
||||
<span class="status-modal-value" id="statusModalCustomer">-</span>
|
||||
</div>
|
||||
<div class="status-modal-row status-transition-row">
|
||||
<div class="status-transition">
|
||||
<span class="status-badge" id="statusModalFrom">-</span>
|
||||
<span class="status-arrow">→</span>
|
||||
<span class="status-badge" id="statusModalTo">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="status-modal-message" id="statusModalMessage">
|
||||
Are you sure you want to change this order's status?
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeStatusChangeModal()">Cancel</button>
|
||||
<button class="btn btn-success status-confirm-btn" id="statusConfirmBtn" onclick="confirmStatusChange()">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Modal -->
|
||||
<div class="modal" id="cancelModal">
|
||||
<div class="modal-content">
|
||||
<h2>Cancel Order</h2>
|
||||
<p>Are you sure you want to cancel this order?</p>
|
||||
<div class="form-group">
|
||||
<label for="cancelReason">Reason (optional):</label>
|
||||
<textarea id="cancelReason" rows="3" placeholder="Enter cancellation reason..."></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeCancelModal()">Cancel</button>
|
||||
<button class="btn btn-danger" onclick="confirmCancel()">Confirm Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Details Modal -->
|
||||
<div class="modal" id="detailsModal">
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header">
|
||||
<h2>Order Details</h2>
|
||||
<button class="close-btn" onclick="closeDetailsModal()">×</button>
|
||||
</div>
|
||||
<div id="orderDetailsContent">
|
||||
<!-- Content will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const config = {
|
||||
dashboardRefreshInterval: <%= config.dashboardRefreshInterval || 10000 %>
|
||||
};
|
||||
</script>
|
||||
<script src="/public/js/common.js"></script>
|
||||
<script src="/public/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
80
views/login.ejs
Normal file
80
views/login.ejs
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Kitchen Agent</title>
|
||||
<link rel="stylesheet" href="/public/css/style.css">
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>Kitchen Agent</h1>
|
||||
<h2>Login</h2>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-error">
|
||||
<%= error %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<form action="/auth/login" method="POST" id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="login">Email</label>
|
||||
<input type="email" id="login" name="login" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" id="submitBtn">
|
||||
Login
|
||||
</button>
|
||||
|
||||
<!-- Invisible reCAPTCHA -->
|
||||
<div class="g-recaptcha"
|
||||
data-sitekey="<%= recaptchaSiteKey %>"
|
||||
data-callback="onRecaptchaSuccess"
|
||||
data-size="invisible">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>ThinkLink Kitchen Agent System</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('loginForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Logging in...';
|
||||
|
||||
// Execute reCAPTCHA
|
||||
grecaptcha.execute();
|
||||
});
|
||||
|
||||
function onRecaptchaSuccess(token) {
|
||||
// reCAPTCHA token obtained, submit form
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// Reset button if reCAPTCHA fails
|
||||
window.addEventListener('load', function() {
|
||||
if (window.grecaptcha) {
|
||||
grecaptcha.ready(function() {
|
||||
console.log('reCAPTCHA ready');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
523
views/settings.ejs
Normal file
523
views/settings.ejs
Normal file
@@ -0,0 +1,523 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings - Kitchen Agent</title>
|
||||
<link rel="stylesheet" href="/public/css/style.css">
|
||||
</head>
|
||||
<body class="settings-page">
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<h1>Settings</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/dashboard" class="btn btn-secondary">Back to Dashboard</a>
|
||||
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-container">
|
||||
<% if (message) { %>
|
||||
<div class="alert alert-success">
|
||||
<%= message %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-error">
|
||||
<%= error %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="general">General</button>
|
||||
<button class="tab-btn" data-tab="printer">Printer</button>
|
||||
<button class="tab-btn" data-tab="template">Receipt Template</button>
|
||||
</div>
|
||||
|
||||
<form action="/settings/save" method="POST" id="settingsForm">
|
||||
|
||||
<!-- General Settings Tab -->
|
||||
<div class="tab-content active" id="general-tab">
|
||||
<h2>General Settings</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="selectedBotId">Select Bot/Assistant</label>
|
||||
<select id="selectedBotId" name="selectedBotId" required>
|
||||
<option value="">-- Select Bot --</option>
|
||||
<% bots.forEach(bot => { %>
|
||||
<% const botConfig = JSON.parse(bot.bot_config || '{}'); %>
|
||||
<option value="<%= bot.id %>" <%= config.selectedBotId == bot.id ? 'selected' : '' %>>
|
||||
<%= botConfig.name || 'Bot #' + bot.id %>
|
||||
</option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<small>Select which bot's orders to display</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pollingInterval">Order Polling Interval (milliseconds)</label>
|
||||
<input type="number" id="pollingInterval" name="pollingInterval"
|
||||
value="<%= config.pollingInterval || 15000 %>" min="5000" max="60000" step="1000">
|
||||
<small>How often to check for new orders (default: 15000 = 15 seconds)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dashboardRefreshInterval">Dashboard Refresh Interval (milliseconds)</label>
|
||||
<input type="number" id="dashboardRefreshInterval" name="dashboardRefreshInterval"
|
||||
value="<%= config.dashboardRefreshInterval || 10000 %>" min="5000" max="60000" step="1000">
|
||||
<small>How often to refresh the dashboard display (default: 10000 = 10 seconds)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="showOrderStats" <%= config.showOrderStats !== 'false' ? 'checked' : '' %>>
|
||||
Show Order Statistics Bar
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 30px; margin-bottom: 15px; border-top: 2px solid #e0e0e0; padding-top: 20px;">Sound Notifications</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="soundNotificationsEnabled" name="soundNotificationsEnabled" <%= config.soundNotificationsEnabled !== 'false' ? 'checked' : '' %>>
|
||||
Enable Sound Notifications
|
||||
</label>
|
||||
<small>Play sounds when new orders arrive or orders are canceled</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="soundVolume">Notification Volume</label>
|
||||
<input type="range" id="soundVolume" name="soundVolume" min="0" max="100" value="<%= config.soundVolume || 80 %>" style="width: 100%;">
|
||||
<small>Volume: <span id="volumeValue"><%= config.soundVolume || 80 %></span>%</small>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-top: 20px;">
|
||||
<h4 style="margin-top: 0;">New Order Sound</h4>
|
||||
<div class="form-group">
|
||||
<label>Current Sound File:</label>
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
<%= config.newOrderSoundPath || '/public/sounds/new-order-notification.mp3' %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Upload Custom Sound (optional)</label>
|
||||
<input type="file" id="newOrderSoundUpload" accept="audio/*">
|
||||
<button type="button" class="btn btn-secondary" onclick="uploadSound('newOrder')">Upload New Order Sound</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="testSound('newOrder')">🔊 Test Sound</button>
|
||||
<small>Supported formats: MP3, WAV, OGG</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-top: 20px;">
|
||||
<h4 style="margin-top: 0;">Canceled Order Sound</h4>
|
||||
<div class="form-group">
|
||||
<label>Current Sound File:</label>
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
<%= config.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3' %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Upload Custom Sound (optional)</label>
|
||||
<input type="file" id="canceledOrderSoundUpload" accept="audio/*">
|
||||
<button type="button" class="btn btn-secondary" onclick="uploadSound('canceled')">Upload Canceled Sound</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="testSound('canceled')">🔊 Test Sound</button>
|
||||
<small>Supported formats: MP3, WAV, OGG</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer Management Tab -->
|
||||
<div class="tab-content" id="printer-tab">
|
||||
<h2>Printer Management</h2>
|
||||
<p class="section-description">Configure individual printers with specific paper sizes, templates, and settings.</p>
|
||||
|
||||
<div class="settings-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h3 style="margin: 0;">Configured Printers</h3>
|
||||
<button type="button" class="btn btn-primary" onclick="openAddPrinterModal()">+ Add Printer</button>
|
||||
</div>
|
||||
|
||||
<div id="printer-cards-container" class="printer-cards-container">
|
||||
<!-- Printer cards will be loaded here dynamically -->
|
||||
<div class="loading">Loading printers...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt Template Tab -->
|
||||
<div class="tab-content" id="template-tab">
|
||||
<h2>Receipt Template</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="headerText">Header Text</label>
|
||||
<textarea id="headerText" name="headerText" rows="2" placeholder="KITCHEN ORDER"><%= typeof config.headerText !== 'undefined' ? config.headerText : '' %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="footerText">Footer Text</label>
|
||||
<textarea id="footerText" name="footerText" rows="2" placeholder="Thank you!"><%= typeof config.footerText !== 'undefined' ? config.footerText : '' %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="qrCodeEnabled" <%= config.qrCodeEnabled !== 'false' ? 'checked' : '' %>>
|
||||
Print QR Code on Receipt
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qrCodeSize">QR Code Size</label>
|
||||
<input type="number" id="qrCodeSize" name="qrCodeSize" min="2" max="8" value="<%= config.qrCodeSize || 3 %>">
|
||||
<small>Cell size (2–8). Larger = bigger QR.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qrCodeCorrection">QR Error Correction</label>
|
||||
<select id="qrCodeCorrection" name="qrCodeCorrection">
|
||||
<option value="L" <%= (config.qrCodeCorrection || 'M') === 'L' ? 'selected' : '' %>>L (Low)</option>
|
||||
<option value="M" <%= (config.qrCodeCorrection || 'M') === 'M' ? 'selected' : '' %>>M (Medium)</option>
|
||||
<option value="Q" <%= (config.qrCodeCorrection || 'M') === 'Q' ? 'selected' : '' %>>Q (Quartile)</option>
|
||||
<option value="H" <%= (config.qrCodeCorrection || 'M') === 'H' ? 'selected' : '' %>>H (High)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qrCodeContentTemplate">QR Content Template</label>
|
||||
<input type="text" id="qrCodeContentTemplate" name="qrCodeContentTemplate" value="<%= config.qrCodeContentTemplate || 'ORDER-{id}' %>">
|
||||
<small>Use placeholders: {id}, {total}, {type}, {createdAt}</small>
|
||||
</div>
|
||||
|
||||
<h3>Show/Hide Sections</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="showCustomerInfo" <%= config.showCustomerInfo !== 'false' ? 'checked' : '' %>>
|
||||
Show Customer Information
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="showOrderItems" <%= config.showOrderItems !== 'false' ? 'checked' : '' %>>
|
||||
Show Order Items
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="showPrices" <%= config.showPrices !== 'false' ? 'checked' : '' %>>
|
||||
Show Prices
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="showTimestamps" <%= config.showTimestamps !== 'false' ? 'checked' : '' %>>
|
||||
Show Timestamps
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Logo Image (optional)</label>
|
||||
<input type="file" id="logoUpload" accept="image/*">
|
||||
<button type="button" class="btn btn-secondary" onclick="uploadLogo()">Upload Logo</button>
|
||||
<small>Upload a logo image to print at the top of receipts</small>
|
||||
<% if (config.logoPath) { %>
|
||||
<div class="current-logo">
|
||||
<p>Current logo: <%= config.logoPath %></p>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<h3>Business Information</h3>
|
||||
<div class="form-group">
|
||||
<label for="businessName">Business Name</label>
|
||||
<input type="text" id="businessName" name="businessName" value="<%= config.businessName || '' %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessAddress">Address</label>
|
||||
<input type="text" id="businessAddress" name="businessAddress" value="<%= config.businessAddress || '' %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessPhone">Phone</label>
|
||||
<input type="text" id="businessPhone" name="businessPhone" value="<%= config.businessPhone || '' %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessWebsite">Website</label>
|
||||
<input type="text" id="businessWebsite" name="businessWebsite" value="<%= config.businessWebsite || '' %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessEmail">Email</label>
|
||||
<input type="text" id="businessEmail" name="businessEmail" value="<%= config.businessEmail || '' %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessContactSize">Contact Font Size</label>
|
||||
<select id="businessContactSize" name="businessContactSize">
|
||||
<option value="normal" <%= (config.businessContactSize || 'normal') === 'normal' ? 'selected' : '' %>>Normal</option>
|
||||
<option value="large" <%= (config.businessContactSize || 'normal') === 'large' ? 'selected' : '' %>>Large</option>
|
||||
</select>
|
||||
<small>Controls the size of address/phone/website/email in header.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary btn-large">Save All Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Printer Configuration Modal -->
|
||||
<div class="modal" id="printerConfigModal">
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header">
|
||||
<h2 id="printerModalTitle">Add Printer</h2>
|
||||
<button class="close-btn" onclick="closePrinterModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="printer-modal-body">
|
||||
<div class="printer-modal-tabs">
|
||||
<button class="printer-modal-tab-btn active" data-tab="connection">Connection</button>
|
||||
<button class="printer-modal-tab-btn" data-tab="paper">Paper & Format</button>
|
||||
<button class="printer-modal-tab-btn" data-tab="template">Template</button>
|
||||
<button class="printer-modal-tab-btn" data-tab="business">Business Info</button>
|
||||
<button class="printer-modal-tab-btn" data-tab="logo">Logo & Branding</button>
|
||||
<button class="printer-modal-tab-btn" data-tab="qrcode">QR Code</button>
|
||||
</div>
|
||||
|
||||
<form id="printerConfigForm" onsubmit="return false;">
|
||||
<input type="hidden" id="printer_id" name="id">
|
||||
|
||||
<!-- Connection Tab -->
|
||||
<div class="printer-modal-tab-content active" id="connection-tab-content">
|
||||
<div class="form-group">
|
||||
<label for="printer_name">Printer Name *</label>
|
||||
<input type="text" id="printer_name" name="name" required placeholder="e.g., Kitchen Printer - 80mm">
|
||||
<small>Give this printer a descriptive name</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="printer_type_select">Connection Type *</label>
|
||||
<select id="printer_type_select" name="type" required>
|
||||
<option value="network">Network (TCP/IP)</option>
|
||||
<option value="com">Serial Port (COM)</option>
|
||||
<option value="usb">USB</option>
|
||||
<option value="system">Windows System Printer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="printer_interface">Connection Address *</label>
|
||||
<input type="text" id="printer_interface" name="interface" required placeholder="e.g., 192.168.1.100:9100, COM1, /dev/usb/lp0">
|
||||
<small id="interface_hint">Enter IP:Port for network printers</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="button" class="btn btn-secondary" onclick="detectPrintersForModal()">Detect Available Printers</button>
|
||||
<div id="detected-printers-list" style="margin-top: 10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paper & Format Tab -->
|
||||
<div class="printer-modal-tab-content" id="paper-tab-content">
|
||||
<div class="form-group">
|
||||
<label for="paper_format">Paper Format *</label>
|
||||
<select id="paper_format" name="paper_format" required>
|
||||
<option value="58mm">58mm Thermal</option>
|
||||
<option value="80mm" selected>80mm Thermal</option>
|
||||
<option value="letter">Letter Size (8.5 x 11)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="paper_width">Character Width *</label>
|
||||
<input type="number" id="paper_width" name="paper_width" value="48" min="20" max="100" required>
|
||||
<small>58mm: 32 chars | 80mm: 48 chars | Letter: 80+ chars</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="printer_type_model">Printer Model/Brand *</label>
|
||||
<select id="printer_type_model" name="printer_type" required>
|
||||
<option value="epson" selected>Epson</option>
|
||||
<option value="star">Star</option>
|
||||
<option value="tanca">Tanca</option>
|
||||
<option value="brother">Brother</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="font_size">Font Size</label>
|
||||
<select id="font_size" name="font_size">
|
||||
<option value="small">Small</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="line_style">Line Style</label>
|
||||
<select id="line_style" name="line_style">
|
||||
<option value="single" selected>Single (─)</option>
|
||||
<option value="double">Double (═)</option>
|
||||
<option value="dashed">Dashed (- -)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Tab -->
|
||||
<div class="printer-modal-tab-content" id="template-tab-content">
|
||||
<div class="form-group">
|
||||
<label for="header_text">Header Text</label>
|
||||
<textarea id="header_text" name="header_text" rows="2" placeholder="KITCHEN ORDER"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="footer_text">Footer Text</label>
|
||||
<textarea id="footer_text" name="footer_text" rows="2" placeholder="Thank you!"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show_customer_info" name="show_customer_info" checked>
|
||||
Show Customer Information
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show_order_items" name="show_order_items" checked>
|
||||
Show Order Items
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show_prices" name="show_prices" checked>
|
||||
Show Prices
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show_timestamps" name="show_timestamps" checked>
|
||||
Show Timestamps
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Info Tab -->
|
||||
<div class="printer-modal-tab-content" id="business-tab-content">
|
||||
<div class="form-group">
|
||||
<label for="business_name">Business Name</label>
|
||||
<input type="text" id="business_name" name="business_name" placeholder="Your Business Name">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="business_address">Address</label>
|
||||
<input type="text" id="business_address" name="business_address" placeholder="123 Main St, City, State ZIP">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="business_phone">Phone</label>
|
||||
<input type="text" id="business_phone" name="business_phone" placeholder="(555) 123-4567">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="business_website">Website</label>
|
||||
<input type="text" id="business_website" name="business_website" placeholder="www.example.com">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="business_email">Email</label>
|
||||
<input type="email" id="business_email" name="business_email" placeholder="contact@example.com">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="business_contact_size">Contact Info Size</label>
|
||||
<select id="business_contact_size" name="business_contact_size">
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo & Branding Tab -->
|
||||
<div class="printer-modal-tab-content" id="logo-tab-content">
|
||||
<div class="form-group">
|
||||
<label>Logo Image</label>
|
||||
<input type="file" id="logo_upload_modal" accept="image/*">
|
||||
<button type="button" class="btn btn-secondary" onclick="uploadLogoForPrinter()" style="margin-top: 10px;">Upload Logo</button>
|
||||
<small>Upload a logo for this specific printer</small>
|
||||
<div id="logo_preview" style="margin-top: 10px;"></div>
|
||||
<input type="hidden" id="logo_path" name="logo_path">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="logo_max_width_dots">Logo Max Width (dots)</label>
|
||||
<input type="number" id="logo_max_width_dots" name="logo_max_width_dots" min="100" max="800" placeholder="Auto-detect based on paper width">
|
||||
<small>58mm: ~384 dots | 80mm: ~576 dots | Leave empty for auto</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Tab -->
|
||||
<div class="printer-modal-tab-content" id="qrcode-tab-content">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="qr_code_enabled" name="qr_code_enabled" checked>
|
||||
Print QR Code on Receipt
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qr_code_size">QR Code Size</label>
|
||||
<input type="number" id="qr_code_size" name="qr_code_size" value="3" min="2" max="8">
|
||||
<small>Cell size (2-8). Larger = bigger QR code</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qr_code_correction">Error Correction Level</label>
|
||||
<select id="qr_code_correction" name="qr_code_correction">
|
||||
<option value="L">L (Low - 7% recovery)</option>
|
||||
<option value="M" selected>M (Medium - 15% recovery)</option>
|
||||
<option value="Q">Q (Quartile - 25% recovery)</option>
|
||||
<option value="H">H (High - 30% recovery)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qr_code_content_template">QR Content Template</label>
|
||||
<input type="text" id="qr_code_content_template" name="qr_code_content_template" value="ORDER-{id}" placeholder="ORDER-{id}">
|
||||
<small>Use: {id}, {total}, {type}, {createdAt}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions" style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #e0e0e0;">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="is_default" name="is_default">
|
||||
Set as Default Printer
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="is_enabled" name="is_enabled" checked>
|
||||
Enabled (Print to this printer)
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="closePrinterModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" onclick="savePrinterConfig()">Save Printer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/public/js/common.js"></script>
|
||||
<script src="/public/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user