modified: Dockerfile

modified:   README.md
	modified:   app.py
	new file:   blueprints/__init__.py
	new file:   blueprints/__pycache__/.gitignore
	new file:   blueprints/auth.py
	new file:   blueprints/finance.py
	new file:   blueprints/inventory.py
	new file:   blueprints/pos.py
	new file:   blueprints/sales.py
	new file:   core/__pycache__/.gitignore
	new file:   core/db.py
	new file:   core/db/.gitignore
	new file:   core/events.py
	new file:   core/openfood.py
	new file:   core/utils.py
	modified:   static/style.css
	modified:   templates/checkout.html
	modified:   templates/dicom.html
	modified:   templates/login.html
	modified:   templates/macros/base.html
	modified:   templates/macros/modals.html
	modified:   templates/macros/navbar.html
This commit is contained in:
2026-05-21 00:05:31 -04:00
parent c2373c3ed6
commit a5babd8131
23 changed files with 2102 additions and 1169 deletions

View File

@@ -2,22 +2,25 @@ FROM python:3.11-slim
WORKDIR /app
# Install dependencies
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code
COPY app.py .
COPY templates/ ./templates/
COPY static/ ./static/
#COPY .env .
COPY . .
# Create the folder structure for the volume mounts
RUN mkdir -p /app/static/cache
# Create necessary directories
RUN mkdir -p /app/db /app/static/cache
# Expose port
EXPOSE 5000
# Run with unbuffered output so you can actually see the logs in Portainer
# Run with unbuffered output
ENV PYTHONUNBUFFERED=1
CMD ["python", "app.py"]

View File

@@ -1,4 +1,4 @@
# SekiPOS v2.2 🍫🥤
# SekiPOS v3.0 🍫🥤
A reactive POS inventory system for software engineers with a snack addiction. Features real-time UI updates, automatic product discovery via Open Food Facts, and local image caching.

816
app.py
View File

@@ -1,821 +1,79 @@
import os
import sys
import sqlite3
import requests
from flask import send_file
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_from_directory
from flask_socketio import SocketIO, emit
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
import mimetypes
import time
import uuid
from datetime import datetime
import zipfile
import io
import webview
import threading
from flask import Flask, redirect, url_for, send_file, jsonify
from flask_login import login_required, current_user
from werkzeug.security import generate_password_hash
import webview
from flask_socketio import SocketIO
from core.utils import get_bundled_path, get_persistent_path
from core.db import init_db as init_db_core, get_db_connection
from core.events import socketio
from blueprints.auth import auth_bp, init_login_manager
from blueprints.finance import finance_bp
from blueprints.inventory import inventory_bp
from blueprints.pos import pos_bp
from blueprints.sales import sales_bp
# --- PYINSTALLER WINDOWED MODE FIX ---
# If running as a compiled exe, redirect stdout/stderr so Flask doesn't crash
if getattr(sys, 'frozen', False) and sys.platform == "win32":
# Force output to a real file instead of the console
log_file = os.path.join(os.path.dirname(sys.executable), 'seki_crash.log')
sys.stdout = open(log_file, 'w', encoding='utf-8')
sys.stderr = sys.stdout
# from dotenv import load_dotenv
# load_dotenv()
# MP_ACCESS_TOKEN = os.getenv('MP_ACCESS_TOKEN')
# MP_TERMINAL_ID = os.getenv('MP_TERMINAL_ID')
# --- PATH HELPERS FOR PYINSTALLER ---
def get_bundled_path(relative_path):
"""Path for read-only files packed inside the .exe (templates, static)"""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.abspath(os.path.dirname(__file__))
return os.path.join(base_path, relative_path)
def get_persistent_path(relative_path):
"""Path for read/write files that must survive reboots (db, cache)"""
if getattr(sys, 'frozen', False):
base_path = os.path.dirname(sys.executable)
else:
base_path = os.path.abspath(os.path.dirname(__file__))
return os.path.join(base_path, relative_path)
# --- FLASK INIT ---
app = Flask(
__name__,
template_folder=get_bundled_path('templates'),
static_folder=get_bundled_path('static')
)
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
# --- AUTH SETUP (Do not delete this) ---
login_manager = LoginManager(app)
login_manager.login_view = 'login'
app.config['SECRET_KEY'] = 'seki_super_secret_key_99'
# --- DIRECTORY SETUP ---
DB_DIR = get_persistent_path('db')
os.makedirs(DB_DIR, exist_ok=True)
DB_FILE = os.path.join(DB_DIR, "pos_database.db")
app.config['DB_FILE'] = DB_FILE
CACHE_DIR = get_persistent_path(os.path.join('static', 'cache'))
os.makedirs(CACHE_DIR, exist_ok=True)
app.config['CACHE_DIR'] = CACHE_DIR
# --- MODELS ---
class User(UserMixin):
def __init__(self, id, username):
self.id = id
self.username = username
# --- BLUEPRINT REGISTRATION ---
app.register_blueprint(auth_bp)
app.register_blueprint(finance_bp)
app.register_blueprint(inventory_bp)
app.register_blueprint(pos_bp)
app.register_blueprint(sales_bp)
# --- DATABASE LOGIC ---
def init_db():
with sqlite3.connect(DB_FILE) as conn:
conn.execute('''CREATE TABLE IF NOT EXISTS users
(id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT)''')
# Updated table definition
conn.execute('''CREATE TABLE IF NOT EXISTS products
(barcode TEXT PRIMARY KEY,
name TEXT,
price REAL,
image_url TEXT,
stock REAL DEFAULT 0,
unit_type TEXT DEFAULT 'unit')''')
# Add these two tables for sales history
conn.execute('''CREATE TABLE IF NOT EXISTS sales
(id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP,
total REAL,
payment_method TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS sale_items
(id INTEGER PRIMARY KEY AUTOINCREMENT,
sale_id INTEGER,
barcode TEXT,
name TEXT,
price REAL,
quantity REAL,
subtotal REAL,
FOREIGN KEY(sale_id) REFERENCES sales(id))''')
conn.execute('''CREATE TABLE IF NOT EXISTS dicom
(id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
amount REAL DEFAULT 0,
notes TEXT,
image_url TEXT,
last_updated TEXT DEFAULT CURRENT_TIMESTAMP)''')
# Default user logic remains same...
user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone()
if not user:
hashed_pw = generate_password_hash('choripan1234')
conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', ('admin', hashed_pw))
conn.commit()
init_login_manager(app)
socketio.init_app(app, cors_allowed_origins="*", async_mode='threading')
@login_manager.user_loader
def load_user(user_id):
with sqlite3.connect(DB_FILE) as conn:
user = conn.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)).fetchone()
return User(user[0], user[1]) if user else None
def download_image(url, barcode):
if not url or not url.startswith('http'):
return url
try:
headers = {'User-Agent': 'SekiPOS/1.2'}
# Use stream=True to check headers before downloading the whole file
with requests.get(url, headers=headers, stream=True, timeout=5) as r:
r.raise_for_status()
# Detect extension from Content-Type header
content_type = r.headers.get('content-type')
ext = mimetypes.guess_extension(content_type) or '.jpg'
local_filename = f"{barcode}{ext}"
local_path = os.path.join(CACHE_DIR, local_filename)
# Save the file
with open(local_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return f"/static/cache/{local_filename}"
except Exception as e:
print(f"Download failed: {e}")
return url
def fetch_from_openfoodfacts(barcode):
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
try:
headers = {'User-Agent': 'SekiPOS/1.0'}
resp = requests.get(url, headers=headers, timeout=5).json()
if resp.get('status') == 1:
p = resp.get('product', {})
name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown')
imgs = p.get('selected_images', {}).get('front', {}).get('display', {})
img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '')
if img_url:
local_img = download_image(img_url, barcode)
return {"name": name, "image": local_img}
return {"name": name, "image": None}
except Exception as e:
print(f"API Error: {e}")
return None
# --- ROUTES ---
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
user_in = request.form.get('username')
pass_in = request.form.get('password')
with sqlite3.connect(DB_FILE) as conn:
user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone()
if user and check_password_hash(user[2], pass_in):
login_user(User(user[0], user[1]))
return redirect(url_for('inventory'))
flash('Invalid credentials.')
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('login'))
# --- DATABASE INITIALIZATION ---
init_db_core(DB_FILE)
# --- ROOT ROUTE ---
@app.route('/')
@login_required
def defaultRoute():
return redirect(url_for('inventory'))
@app.route('/inventory')
@login_required
def inventory():
with sqlite3.connect(DB_FILE) as conn:
products = conn.execute('SELECT * FROM products').fetchall()
return render_template('inventory.html', active_page='inventory', products=products, user=current_user)
@app.route("/checkout")
@login_required
def checkout():
with sqlite3.connect(DB_FILE) as conn:
# Fetching the same columns the scanner expects
products = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products').fetchall()
return render_template("checkout.html", active_page='checkout', user=current_user, products=products)
@app.route('/dicom')
@login_required
def dicom():
with sqlite3.connect(DB_FILE) as conn:
debtors = conn.execute('SELECT id, name, amount, notes, datetime(last_updated, "localtime"), image_url FROM dicom ORDER BY amount DESC').fetchall()
return render_template('dicom.html', active_page='dicom', user=current_user, debtors=debtors)
@app.route('/sales')
@login_required
def sales():
selected_date = request.args.get('date')
payment_method = request.args.get('payment_method')
page = request.args.get('page', 1, type=int)
per_page = 100
with sqlite3.connect(DB_FILE) as conn:
cur = conn.cursor()
# 1. Calculate the top 3 cards (Now respecting the payment method!)
target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0]
daily_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?"
week_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')"
month_query = "SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')"
daily_params = [target_date]
week_params = []
month_params = []
# If a payment method is selected, inject it into the top card queries
if payment_method:
daily_query += " AND payment_method = ?"
week_query += " AND payment_method = ?"
month_query += " AND payment_method = ?"
daily_params.append(payment_method)
week_params.append(payment_method)
month_params.append(payment_method)
stats = {
"daily": cur.execute(daily_query, tuple(daily_params)).fetchone()[0] or 0,
"week": cur.execute(week_query, tuple(week_params)).fetchone()[0] or 0,
"month": cur.execute(month_query, tuple(month_params)).fetchone()[0] or 0
}
# 2. Dynamic query builder for the main table and pagination
base_query = "FROM sales WHERE 1=1"
params = []
if selected_date:
base_query += " AND date(date, 'localtime') = ?"
params.append(selected_date)
if payment_method:
base_query += " AND payment_method = ?"
params.append(payment_method)
# Get total count and sum for the current table filters BEFORE applying limit/offset
stats_query = f"SELECT COUNT(*), SUM(total) {base_query}"
count_res, sum_res = cur.execute(stats_query, tuple(params)).fetchone()
total_count = count_res or 0
total_sum = sum_res or 0
total_pages = (total_count + per_page - 1) // per_page
filtered_stats = {
"total": total_sum,
"count": total_count
}
# Fetch the actual 100 rows for the current page
offset = (page - 1) * per_page
data_query = f"SELECT id, date, total, payment_method {base_query} ORDER BY date DESC LIMIT ? OFFSET ?"
sales_data = cur.execute(data_query, tuple(params) + (per_page, offset)).fetchall()
return render_template('sales.html',
active_page='sales',
user=current_user,
sales=sales_data,
stats=stats,
filtered_stats=filtered_stats,
selected_date=selected_date,
selected_payment=payment_method,
current_page=page,
total_pages=total_pages)
@app.route("/upsert", methods=["POST"])
@login_required
def upsert():
d = request.form
barcode = d['barcode']
try:
price = float(d['price'])
stock = float(d.get('stock', 0)) # New field
except (ValueError, TypeError):
price = 0.0
stock = 0.0
unit_type = d.get('unit_type', 'unit') # New field (unit or kg)
final_image_path = download_image(d['image_url'], barcode)
with sqlite3.connect(DB_FILE) as conn:
# Updated UPSERT query
conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type)
VALUES (?,?,?,?,?,?)
ON CONFLICT(barcode) DO UPDATE SET
name=excluded.name,
price=excluded.price,
image_url=excluded.image_url,
stock=excluded.stock,
unit_type=excluded.unit_type''',
(barcode, d['name'], price, final_image_path, stock, unit_type))
conn.commit()
return redirect(url_for('inventory'))
@app.route('/delete/<barcode>', methods=['POST'])
@login_required
def delete(barcode):
with sqlite3.connect(DB_FILE) as conn:
conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,))
conn.commit()
# Clean up cache
img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg")
if os.path.exists(img_p): os.remove(img_p)
socketio.emit('product_deleted', {"barcode": barcode})
return redirect(url_for('inventory'))
@app.route('/scan', methods=['GET'])
def scan():
barcode = request.args.get('content', '').replace('{content}', '')
if not barcode:
return jsonify({"status": "error", "message": "empty barcode"}), 400
with sqlite3.connect(DB_FILE) as conn:
# Fixed: Selecting all 6 necessary columns
p = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products WHERE barcode = ?', (barcode,)).fetchone()
if p:
# Now matches the 6 columns in the SELECT statement
barcode_val, name, price, image_path, stock, unit_type = p
if image_path and image_path.startswith('/static/'):
clean_path = image_path.split('?')[0].lstrip('/')
if not os.path.exists(clean_path):
ext_data = fetch_from_openfoodfacts(barcode_val)
if ext_data and ext_data.get('image'):
image_path = ext_data['image']
with sqlite3.connect(DB_FILE) as conn:
conn.execute('UPDATE products SET image_url = ? WHERE barcode = ?', (image_path, barcode_val))
conn.commit()
product_data = {
"barcode": barcode_val,
"name": name,
"price": int(price),
"image": image_path,
"stock": stock,
"unit_type": unit_type
}
socketio.emit('new_scan', product_data)
return jsonify({"status": "ok", "data": product_data}), 200
# 2. Product not in DB, try external API
ext = fetch_from_openfoodfacts(barcode)
if ext:
# We found it externally, but it's still a 404 relative to our local DB
external_data = {
"barcode": barcode,
"name": ext['name'],
"image": ext['image'],
"source": "openfoodfacts"
}
socketio.emit('scan_error', external_data)
return jsonify({"status": "not_found", "data": external_data}), 404
# 3. Truly not found anywhere
socketio.emit('scan_error', {"barcode": barcode})
return jsonify({"status": "not_found", "data": {"barcode": barcode}}), 404
@app.route('/static/cache/<path:filename>')
def serve_cache(filename):
return send_from_directory(CACHE_DIR, filename)
@app.route('/bulk_price_update', methods=['POST'])
@login_required
def bulk_price_update():
data = request.get_json()
barcodes = data.get('barcodes', [])
new_price = data.get('new_price')
if not barcodes or new_price is None:
return jsonify({"error": "Missing data"}), 400
try:
with sqlite3.connect(DB_FILE) as conn:
# Use executemany for efficiency
params = [(float(new_price), b) for b in barcodes]
conn.executemany('UPDATE products SET price = ? WHERE barcode = ?', params)
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk update failed: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/bulk_delete', methods=['POST'])
@login_required
def bulk_delete():
data = request.get_json()
barcodes = data.get('barcodes', [])
if not barcodes:
return jsonify({"error": "No barcodes provided"}), 400
try:
with sqlite3.connect(DB_FILE) as conn:
# Delete records from DB
conn.execute(f'DELETE FROM products WHERE barcode IN ({",".join(["?"]*len(barcodes))})', barcodes)
conn.commit()
# Clean up cache for each deleted product
for barcode in barcodes:
# This is a bit naive as it only checks .jpg, but matches your existing delete logic
img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg")
if os.path.exists(img_p):
os.remove(img_p)
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk delete failed: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/upload_image', methods=['POST'])
@login_required
def upload_image():
if 'image' not in request.files or 'barcode' not in request.form:
return jsonify({"error": "Missing data"}), 400
file = request.files['image']
barcode = request.form['barcode']
if file.filename == '' or not barcode:
return jsonify({"error": "Invalid data"}), 400
filename = f"{barcode}.jpg"
filepath = os.path.join(CACHE_DIR, filename)
file.save(filepath)
timestamp = int(time.time())
return jsonify({"status": "success", "image_url": f"/static/cache/{filename}?t={timestamp}"}), 200
@app.route('/api/scale/weight', methods=['POST'])
def update_scale_weight():
data = request.get_json()
# Assuming the scale sends {"weight": 1250} (in grams)
weight_grams = data.get('weight', 0)
# Optional: Convert to kg if you prefer
weight_kg = round(weight_grams / 1000, 3)
# Broadcast to all connected clients via SocketIO
socketio.emit('scale_update', {
"grams": weight_grams,
"kilograms": weight_kg,
"timestamp": time.time()
})
return jsonify({"status": "received"}), 200
@app.route('/api/checkout', methods=['POST'])
@login_required
def process_checkout():
try:
data = request.get_json()
cart = data.get('cart', [])
payment_method = data.get('payment_method', 'efectivo')
if not cart:
return jsonify({"error": "Cart is empty"}), 400
# Recalculate total on the server because the frontend is a liar
total = sum(item.get('subtotal', 0) for item in cart)
with sqlite3.connect(DB_FILE) as conn:
cur = conn.cursor()
# Let SQLite handle the exact UTC timestamp internally
cur.execute('INSERT INTO sales (date, total, payment_method) VALUES (CURRENT_TIMESTAMP, ?, ?)', (total, payment_method))
sale_id = cur.lastrowid
# Record each item and deduct stock
for item in cart:
cur.execute('''INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal)
VALUES (?, ?, ?, ?, ?, ?)''',
(sale_id, item['barcode'], item['name'], item['price'], item['qty'], item['subtotal']))
# Deduct from inventory (Manual products will safely be ignored here)
cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?', (item['qty'], item['barcode']))
conn.commit()
return jsonify({"status": "success", "sale_id": sale_id}), 200
except Exception as e:
print(f"Checkout Error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/sale/<int:sale_id>')
@login_required
def get_sale_details(sale_id):
with sqlite3.connect(DB_FILE) as conn:
items = conn.execute('SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall()
# Format it as a neat list of dictionaries for JavaScript to digest
item_list = [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items]
return jsonify(item_list), 200
@app.route('/api/sale/<int:sale_id>', methods=['DELETE'])
@login_required
def reverse_sale(sale_id):
try:
with sqlite3.connect(DB_FILE) as conn:
cur = conn.cursor()
# 1. Get the items and quantities from the receipt
items = cur.execute('SELECT barcode, quantity FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall()
# 2. Add the stock back to the inventory
for barcode, qty in items:
# This safely ignores manual products since their fake barcodes won't match any row
cur.execute('UPDATE products SET stock = stock + ? WHERE barcode = ?', (qty, barcode))
# 3. Destroy the evidence
cur.execute('DELETE FROM sale_items WHERE sale_id = ?', (sale_id,))
cur.execute('DELETE FROM sales WHERE id = ?', (sale_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Reverse Sale Error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/dicom/update', methods=['POST'])
@login_required
def update_dicom():
data = request.get_json()
name = data.get('name', '').strip()
amount = float(data.get('amount', 0))
notes = data.get('notes', '')
image_url = data.get('image_url', '')
action = data.get('action')
if not name or amount <= 0:
return jsonify({"error": "Nombre y monto válidos son requeridos"}), 400
if action == 'add':
amount = -amount
with sqlite3.connect(DB_FILE) as conn:
cur = conn.cursor()
cur.execute('''INSERT INTO dicom (name, amount, notes, image_url, last_updated)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
amount = amount + excluded.amount,
notes = excluded.notes,
image_url = CASE WHEN excluded.image_url != "" THEN excluded.image_url ELSE dicom.image_url END,
last_updated = CURRENT_TIMESTAMP''', (name, amount, notes, image_url))
conn.commit()
return jsonify({"status": "success"}), 200
@app.route('/api/dicom/<int:debtor_id>', methods=['DELETE'])
@login_required
def delete_dicom(debtor_id):
try:
with sqlite3.connect(DB_FILE) as conn:
conn.execute('DELETE FROM dicom WHERE id = ?', (debtor_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/settings/update', methods=['POST'])
@login_required
def update_settings():
new_password = request.form.get('password')
profile_pic = request.form.get('profile_pic')
with sqlite3.connect(DB_FILE) as conn:
if new_password and len(new_password) > 0:
hashed_pw = generate_password_hash(new_password)
conn.execute('UPDATE users SET password = ? WHERE id = ?', (hashed_pw, current_user.id))
if profile_pic:
conn.execute('UPDATE users SET profile_pic = ? WHERE id = ?', (profile_pic, current_user.id))
conn.commit()
flash('Configuración actualizada')
return redirect(request.referrer)
@app.route('/export/db')
@login_required
def export_db():
if os.path.exists(DB_FILE):
return send_file(DB_FILE, as_attachment=True, download_name=f"SekiPOS_Backup_{datetime.now().strftime('%Y%m%d')}.db", mimetype='application/x-sqlite3')
return "Error: Database file not found", 404
@app.route('/export/images')
@login_required
def export_images():
if not os.path.exists(CACHE_DIR) or not os.listdir(CACHE_DIR):
return "No images found to export", 404
# Create an in-memory byte stream to hold the zip data
memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(CACHE_DIR):
for file in files:
file_path = os.path.join(root, file)
# Store files using their names only to avoid nesting inside the zip
zf.write(file_path, arcname=file)
memory_file.seek(0)
return send_file(
memory_file,
mimetype='application/zip',
as_attachment=True,
download_name=f"SekiPOS_Images_{datetime.now().strftime('%Y%m%d')}.zip"
)
from datetime import datetime
@app.route('/gastos')
@login_required
def gastos():
# Default to the current month if no filter is applied
selected_month = request.args.get('month', datetime.now().strftime('%Y-%m'))
with sqlite3.connect(DB_FILE) as conn:
cur = conn.cursor()
# Auto-create the table so it doesn't crash on first load
cur.execute('''
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
description TEXT NOT NULL,
amount INTEGER NOT NULL
)
''')
# Calculate totals for the selected month
sales_total = cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
expenses_total = cur.execute("SELECT SUM(amount) FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
# Fetch the expense list
expenses_list = cur.execute("SELECT id, date, description, amount FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ? ORDER BY date DESC", (selected_month,)).fetchall()
return render_template('gastos.html',
active_page='gastos',
user=current_user,
sales_total=sales_total,
expenses_total=expenses_total,
net_profit=sales_total - expenses_total,
expenses=expenses_list,
selected_month=selected_month)
@app.route('/api/gastos', methods=['POST'])
@login_required
def add_gasto():
data = request.get_json()
desc = data.get('description')
amount = data.get('amount')
if not desc or not amount:
return jsonify({"error": "Faltan datos"}), 400
with sqlite3.connect(DB_FILE) as conn:
cur = conn.cursor()
cur.execute("INSERT INTO expenses (description, amount) VALUES (?, ?)", (desc, int(amount)))
conn.commit()
return jsonify({"success": True})
@app.route('/api/gastos/<int:gasto_id>', methods=['DELETE'])
@login_required
def delete_gasto(gasto_id):
with sqlite3.connect(DB_FILE) as conn:
cur = conn.cursor()
cur.execute("DELETE FROM expenses WHERE id = ?", (gasto_id,))
conn.commit()
return jsonify({"success": True})
# @app.route('/process_payment', methods=['POST'])
# @login_required
# def process_payment():
# data = request.get_json()
# total_amount = data.get('total')
# if not total_amount or total_amount <= 0:
# return jsonify({"error": "Invalid amount"}), 400
# url = "https://api.mercadopago.com/v1/orders"
# headers = {
# "Authorization": f"Bearer {MP_ACCESS_TOKEN}",
# "Content-Type": "application/json",
# "X-Idempotency-Key": str(uuid.uuid4())
# }
# # MP Point API often prefers integer strings for CLP or exact strings
# # We use int() here if you are dealing with CLP (no cents)
# formatted_amount = str(int(float(total_amount)))
# payload = {
# "type": "point",
# "external_reference": f"ref_{int(time.time())}",
# "description": "Venta SekiPOS",
# "expiration_time": "PT16M",
# "transactions": {
# "payments": [
# {
# "amount": formatted_amount
# }
# ]
# },
# "config": {
# "point": {
# "terminal_id": MP_TERMINAL_ID,
# "print_on_terminal": "no_ticket"
# },
# "payment_method": {
# "default_type": "credit_card"
# }
# },
# "integration_data": {
# "platform_id": "dev_1234567890",
# "integrator_id": "dev_1234567890"
# },
# "taxes": [
# {
# "payer_condition": "payment_taxable_iva"
# }
# ]
# }
# try:
# # Verify the payload in your terminal if it fails again
# response = requests.post(url, json=payload, headers=headers)
# if response.status_code != 201 and response.status_code != 200:
# print(f"DEBUG MP ERROR: {response.text}")
# return jsonify(response.json()), response.status_code
# except Exception as e:
# return jsonify({"error": str(e)}), 500
# @app.route('/api/mp-webhook', methods=['POST'])
# def webhook_notify():
# data = request.get_json()
# action = data.get('action', 'unknown')
# # Emitimos a todos los clientes conectados
# socketio.emit('payment_update', {
# "status": action,
# "id": data.get('data', {}).get('id')
# })
# return jsonify({"status": "ok"}), 200
def index():
return redirect(url_for('inventory.inventory'))
# --- RUN FUNCTION ---
def start_server():
# Use socketio.run instead of default app.run
#socketio.run(app, host='127.0.0.1', port=5000)
socketio.run(app, host='127.0.0.1', port=5000, log_output=False, allow_unsafe_werkzeug=True)
def run_standalone():
t = threading.Thread(target=start_server)
t.daemon = True
t.start()
# GIVE FLASK 2 SECONDS TO BOOT UP BEFORE OPENING THE BROWSER
time.sleep(2)
webview.create_window('SekiPOS', 'http://127.0.0.1:5000', width=1366, height=768, resizable=True, fullscreen=False, min_size=(800, 600), maximized=True)
# private_mode=False is the magic flag that allows localStorage to survive.
# It saves data to %APPDATA%\pywebview on Windows.
webview.start(private_mode=False)
if __name__ == '__main__':
init_db()
# For standalone desktop app with embedded browser, use
#run_standalone()
# For docker or traditional server deployment, comment out run_standalone() and uncomment the line below:
socketio.run(app, host='0.0.0.0', port=5000, debug=True)
#run_standalone() # Uncomment for desktop app
socketio.run(app, host='0.0.0.0', port=5000, debug=True)

1
blueprints/__init__.py Normal file
View File

@@ -0,0 +1 @@
# blueprints/__init__.py

2
blueprints/__pycache__/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

60
blueprints/auth.py Normal file
View File

@@ -0,0 +1,60 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from werkzeug.security import check_password_hash, generate_password_hash
from core.db import get_db_connection
auth_bp = Blueprint('auth', __name__)
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
class User(UserMixin):
def __init__(self, id, username):
self.id = id
self.username = username
@login_manager.user_loader
def load_user(user_id):
with get_db_connection() as conn:
user = conn.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)).fetchone()
return User(user[0], user[1]) if user else None
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
user_in = request.form.get('username')
pass_in = request.form.get('password')
with get_db_connection() as conn:
user = conn.execute('SELECT * FROM users WHERE username = ?', (user_in,)).fetchone()
if user and check_password_hash(user[2], pass_in):
login_user(User(user[0], user[1]))
return redirect(url_for('inventory.inventory'))
flash('Invalid credentials.')
return render_template('login.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))
@auth_bp.route('/settings/update', methods=['POST'])
@login_required
def update_settings():
new_password = request.form.get('password')
profile_pic = request.form.get('profile_pic')
with get_db_connection() as conn:
if new_password and len(new_password) > 0:
hashed_pw = generate_password_hash(new_password)
conn.execute('UPDATE users SET password = ? WHERE id = ?', (hashed_pw, current_user.id))
if profile_pic:
conn.execute('UPDATE users SET profile_pic = ? WHERE id = ?', (profile_pic, current_user.id))
conn.commit()
flash('Configuración actualizada')
return redirect(request.referrer)
def init_login_manager(app):
login_manager.init_app(app)

398
blueprints/finance.py Normal file
View File

@@ -0,0 +1,398 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from core.db import get_db_connection
finance_bp = Blueprint('finance', __name__)
@finance_bp.route('/dicom')
@login_required
def dicom():
with get_db_connection() as conn:
debtors = conn.execute('''SELECT d.id, d.name, d.contact_info,
COALESCE(SUM(t.total - t.amount_paid), 0) as total_balance
FROM debtors d
LEFT JOIN debtor_tickets t ON d.id = t.debtor_id
GROUP BY d.id
ORDER BY total_balance DESC''').fetchall()
return render_template('dicom.html', active_page='dicom', user=current_user, debtors=debtors)
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>', methods=['GET'])
@login_required
def get_debtor_details(debtor_id):
with get_db_connection() as conn:
# Get tickets with their remaining balance
tickets = conn.execute('''SELECT id, date, total, amount_paid, status,
total - amount_paid as remaining
FROM debtor_tickets
WHERE debtor_id = ?
ORDER BY date DESC''', (debtor_id,)).fetchall()
# Get items for each ticket
result = []
for t in tickets:
items = conn.execute('''SELECT id, barcode, name, price, quantity, subtotal
FROM debtor_ticket_items
WHERE ticket_id = ?''', (t[0],)).fetchall()
result.append({
"id": t[0],
"date": t[1],
"total": t[2],
"amount_paid": t[3],
"status": t[4],
"remaining": t[5],
"items": [{"id": i[0], "barcode": i[1], "name": i[2], "price": i[3], "qty": i[4], "subtotal": i[5]} for i in items]
})
return jsonify(result)
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>/pay', methods=['POST'])
@login_required
def pay_debtor_ticket(debtor_id):
data = request.get_json()
ticket_id = data.get('ticket_id')
amount = float(data.get('amount', 0))
if not ticket_id or amount <= 0:
return jsonify({"error": "Monto inválido"}), 400
with get_db_connection() as conn:
conn.execute('''UPDATE debtor_tickets
SET amount_paid = amount_paid + ?,
status = CASE WHEN (total - amount_paid - ?) <= 0 THEN 'paid' ELSE 'partial' END
WHERE id = ?''', (amount, amount, ticket_id))
# Update status based on final values
conn.execute('''UPDATE debtor_tickets
SET status = CASE
WHEN total - amount_paid <= 0 THEN 'paid'
WHEN amount_paid > 0 THEN 'partial'
ELSE 'unpaid'
END
WHERE id = ?''', (ticket_id,))
conn.commit()
return jsonify({"status": "success"})
@finance_bp.route('/api/dicom/pay', methods=['POST'])
@login_required
def dicom_pay():
data = request.get_json()
ticket_id = data.get('ticket_id')
amount = float(data.get('amount', 0))
payment_method = data.get('payment_method', 'efectivo')
if not ticket_id or amount <= 0:
return jsonify({"error": "Monto inválido"}), 400
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Update the debtor ticket payment
cur.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?', (amount, ticket_id))
# Update status based on final values
cur.execute('''UPDATE debtor_tickets
SET status = CASE
WHEN total - amount_paid <= 0 THEN 'paid'
WHEN amount_paid > 0 THEN 'partial'
ELSE 'unpaid'
END
WHERE id = ?''', (ticket_id,))
# Insert into sales table to track daily revenue
cur.execute('''INSERT INTO sales (date, total, payment_method)
VALUES (CURRENT_TIMESTAMP, ?, ?)''', (amount, payment_method))
conn.commit()
return jsonify({"status": "success", "amount": amount}), 200
except Exception as e:
print(f"Dicom Pay Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/update', methods=['POST'])
@login_required
def update_dicom():
data = request.get_json()
name = data.get('name', '').strip()
amount = float(data.get('amount', 0))
notes = data.get('notes', '')
image_url = data.get('image_url', '')
action = data.get('action')
if not name or amount <= 0:
return jsonify({"error": "Nombre y monto válidos son requeridos"}), 400
if action == 'add':
amount = -amount
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute('''INSERT INTO dicom (name, amount, notes, image_url, last_updated)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
amount = amount + excluded.amount,
notes = excluded.notes,
image_url = CASE WHEN excluded.image_url != "" THEN excluded.image_url ELSE dicom.image_url END,
last_updated = CURRENT_TIMESTAMP''', (name, amount, notes, image_url))
conn.commit()
return jsonify({"status": "success"}), 200
@finance_bp.route('/api/dicom/<int:debtor_id>', methods=['DELETE'])
@login_required
def delete_dicom(debtor_id):
try:
with get_db_connection() as conn:
conn.execute('DELETE FROM dicom WHERE id = ?', (debtor_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
@finance_bp.route('/gastos')
@login_required
def gastos():
from datetime import datetime
selected_month = request.args.get('month', datetime.now().strftime('%Y-%m'))
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute('''
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
description TEXT NOT NULL,
amount INTEGER NOT NULL
)
''')
sales_total = cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
expenses_total = cur.execute("SELECT SUM(amount) FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
expenses_list = cur.execute("SELECT id, date, description, amount FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ? ORDER BY date DESC", (selected_month,)).fetchall()
return render_template('gastos.html',
active_page='gastos',
user=current_user,
sales_total=sales_total,
expenses_total=expenses_total,
net_profit=sales_total - expenses_total,
expenses=expenses_list,
selected_month=selected_month)
@finance_bp.route('/api/gastos', methods=['POST'])
@login_required
def add_gasto():
data = request.get_json()
desc = data.get('description')
amount = data.get('amount')
if not desc or not amount:
return jsonify({"error": "Faltan datos"}), 400
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("INSERT INTO expenses (description, amount) VALUES (?, ?)", (desc, int(amount)))
conn.commit()
return jsonify({"success": True})
@finance_bp.route('/api/gastos/<int:gasto_id>', methods=['DELETE'])
@login_required
def delete_gasto(gasto_id):
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM expenses WHERE id = ?", (gasto_id,))
conn.commit()
return jsonify({"success": True})
@finance_bp.route('/api/dicom/debtors', methods=['GET'])
@login_required
def get_debtors():
try:
with get_db_connection() as conn:
# Check if table exists
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='debtors'")
if not cur.fetchone():
print("Debtors table does not exist!")
return jsonify([])
cur.execute('SELECT id, name, contact_info FROM debtors ORDER BY name')
debtors = cur.fetchall()
print(f"Found {len(debtors)} debtors:", debtors)
return jsonify([{"id": d[0], "name": d[1], "contact_info": d[2]} for d in debtors])
except Exception as e:
print(f"Error getting debtors: {e}")
import traceback
traceback.print_exc()
return jsonify([])
@finance_bp.route('/api/dicom/checkout', methods=['POST'])
@login_required
def dicom_checkout():
try:
data = request.get_json()
cart = data.get('cart', [])
debtor_name = data.get('debtor_name', '').strip()
contact_info = data.get('contact_info', '').strip()
initial_payment = data.get('initial_payment', 0) or 0
if not cart:
return jsonify({"error": "Carrito vacío"}), 400
if not debtor_name:
return jsonify({"error": "Nombre del deudor requerido"}), 400
total = sum(item.get('subtotal', 0) for item in cart)
with get_db_connection() as conn:
cur = conn.cursor()
# Upsert debtor
cur.execute('''INSERT INTO debtors (name, contact_info)
VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
contact_info = excluded.contact_info''',
(debtor_name, contact_info))
# Get debtor ID
debtor_id = cur.execute('SELECT id FROM debtors WHERE name = ?', (debtor_name,)).fetchone()[0]
# Insert debtor ticket
status = 'partial' if initial_payment > 0 else 'unpaid'
cur.execute('''INSERT INTO debtor_tickets (debtor_id, total, amount_paid, status)
VALUES (?, ?, ?, ?)''',
(debtor_id, total, initial_payment, status))
ticket_id = cur.lastrowid
# Insert ticket items and deduct stock
for item in cart:
cur.execute('''INSERT INTO debtor_ticket_items
(ticket_id, barcode, name, price, quantity, subtotal)
VALUES (?, ?, ?, ?, ?, ?)''',
(ticket_id, item.get('barcode', ''), item.get('name'),
item.get('price'), item.get('qty'), item.get('subtotal')))
# Deduct stock (skip for manual products)
if item.get('barcode') and not item.get('barcode', '').startswith(('MANUAL-', 'VARIOS-', 'RAPIDA-')):
cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?',
(item.get('qty'), item.get('barcode')))
conn.commit()
return jsonify({"status": "success", "ticket_id": ticket_id, "debtor": debtor_name}), 200
except Exception as e:
print(f"Dicom Checkout Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>', methods=['DELETE'])
@login_required
def delete_debtor(debtor_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Delete items first
cur.execute('DELETE FROM debtor_ticket_items WHERE ticket_id IN (SELECT id FROM debtor_tickets WHERE debtor_id = ?)', (debtor_id,))
# Delete tickets
cur.execute('DELETE FROM debtor_tickets WHERE debtor_id = ?', (debtor_id,))
# Delete debtor
cur.execute('DELETE FROM debtors WHERE id = ?', (debtor_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Delete Debtor Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/ticket/<int:ticket_id>', methods=['DELETE'])
@login_required
def delete_ticket(ticket_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Delete items first
cur.execute('DELETE FROM debtor_ticket_items WHERE ticket_id = ?', (ticket_id,))
# Delete ticket
cur.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Delete Ticket Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/item/<int:item_id>', methods=['DELETE'])
@login_required
def delete_item(item_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
# Get item info to update ticket total
item = cur.execute('SELECT ticket_id, subtotal FROM debtor_ticket_items WHERE id = ?', (item_id,)).fetchone()
if not item:
return jsonify({"error": "Item no encontrado"}), 404
ticket_id, item_subtotal = item
# Delete item
cur.execute('DELETE FROM debtor_ticket_items WHERE id = ?', (item_id,))
# Check if ticket has remaining items
remaining_items = cur.execute('SELECT COUNT(*) FROM debtor_ticket_items WHERE ticket_id = ?', (ticket_id,)).fetchone()[0]
if remaining_items == 0:
# Delete ticket if no items left
cur.execute('DELETE FROM debtor_tickets WHERE id = ?', (ticket_id,))
return jsonify({"status": "success", "ticket_deleted": True}), 200
# Update ticket total
cur.execute('UPDATE debtor_tickets SET total = total - ? WHERE id = ?', (item_subtotal, ticket_id))
conn.commit()
return jsonify({"status": "success", "ticket_deleted": False}), 200
except Exception as e:
print(f"Delete Item Error: {e}")
return jsonify({"error": str(e)}), 500
@finance_bp.route('/api/dicom/debtor/<int:debtor_id>/pay-all', methods=['POST'])
@login_required
def pay_all_debtor(debtor_id):
try:
data = request.get_json()
amount = float(data.get('amount', 0))
payment_method = data.get('payment_method', 'efectivo')
if amount <= 0:
return jsonify({"error": "Monto inválido"}), 400
with get_db_connection() as conn:
cur = conn.cursor()
# Get all unpaid/partial tickets for this debtor
tickets = cur.execute('''SELECT id, total, amount_paid, total - amount_paid as remaining
FROM debtor_tickets
WHERE debtor_id = ? AND status != 'paid' ''', (debtor_id,)).fetchall()
for ticket in tickets:
ticket_id = ticket[0]
remaining = ticket[3]
if remaining > 0:
# Pay remaining amount
cur.execute('UPDATE debtor_tickets SET amount_paid = amount_paid + ? WHERE id = ?',
(remaining, ticket_id))
# Update status
cur.execute('''UPDATE debtor_tickets SET status = 'paid' WHERE id = ?''', (ticket_id,))
# Record sale
cur.execute('''INSERT INTO sales (date, total, payment_method)
VALUES (CURRENT_TIMESTAMP, ?, ?)''', (remaining, payment_method))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Pay All Debtor Error: {e}")
return jsonify({"error": str(e)}), 500

112
blueprints/inventory.py Normal file
View File

@@ -0,0 +1,112 @@
import os
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, current_app
from flask_login import login_required, current_user
from core.db import get_db_connection
from core.utils import download_image
from core.events import socketio
inventory_bp = Blueprint('inventory', __name__)
@inventory_bp.route('/inventory')
@login_required
def inventory():
with get_db_connection() as conn:
products = conn.execute('SELECT * FROM products').fetchall()
return render_template('inventory.html', active_page='inventory', products=products, user=current_user)
@inventory_bp.route("/upsert", methods=["POST"])
@login_required
def upsert():
d = request.form
barcode = d['barcode']
price_str = d.get('price', '0')
stock_str = d.get('stock', '0')
try:
price = float(price_str) if price_str else 0.0
stock = float(stock_str) if stock_str else 0.0
except (ValueError, TypeError):
price = 0.0
stock = 0.0
name = d.get('name', '')
image_url = d.get('image_url', '')
unit_type = d.get('unit_type', 'unit')
cache_dir = current_app.config['CACHE_DIR']
final_image_path = download_image(image_url, barcode, cache_dir)
with get_db_connection() as conn:
conn.execute('''INSERT INTO products (barcode, name, price, image_url, stock, unit_type)
VALUES (?,?,?,?,?,?)
ON CONFLICT(barcode) DO UPDATE SET
name=excluded.name,
price=excluded.price,
image_url=excluded.image_url,
stock=excluded.stock,
unit_type=excluded.unit_type''',
(barcode, name, price, final_image_path, stock, unit_type))
conn.commit()
return redirect(url_for('inventory.inventory'))
@inventory_bp.route('/delete/<barcode>', methods=['POST'])
@login_required
def delete(barcode):
cache_dir = current_app.config['CACHE_DIR']
with get_db_connection() as conn:
conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,))
conn.commit()
img_p = os.path.join(cache_dir, f"{barcode}.jpg")
if os.path.exists(img_p): os.remove(img_p)
if socketio:
socketio.emit('product_deleted', {"barcode": barcode})
return redirect(url_for('inventory.inventory'))
@inventory_bp.route('/bulk_price_update', methods=['POST'])
@login_required
def bulk_price_update():
data = request.get_json()
barcodes = data.get('barcodes', [])
new_price = data.get('new_price')
if not barcodes or new_price is None:
return jsonify({"error": "Missing data"}), 400
try:
with get_db_connection() as conn:
params = [(float(new_price), b) for b in barcodes]
conn.executemany('UPDATE products SET price = ? WHERE barcode = ?', params)
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk update failed: {e}")
return jsonify({"error": str(e)}), 500
@inventory_bp.route('/bulk_delete', methods=['POST'])
@login_required
def bulk_delete():
cache_dir = current_app.config['CACHE_DIR']
data = request.get_json()
barcodes = data.get('barcodes', [])
if not barcodes:
return jsonify({"error": "No barcodes provided"}), 400
try:
with get_db_connection() as conn:
conn.execute(f'DELETE FROM products WHERE barcode IN ({",".join(["?"]*len(barcodes))})', barcodes)
conn.commit()
for barcode in barcodes:
img_p = os.path.join(cache_dir, f"{barcode}.jpg")
if os.path.exists(img_p):
os.remove(img_p)
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk delete failed: {e}")
return jsonify({"error": str(e)}), 500

115
blueprints/pos.py Normal file
View File

@@ -0,0 +1,115 @@
import os
import time
from flask import Blueprint, render_template, request, jsonify, current_app
from flask_login import login_required, current_user
from core.db import get_db_connection
from core.openfood import fetch_from_openfoodfacts
from core.events import socketio
pos_bp = Blueprint('pos', __name__)
@pos_bp.route('/checkout')
@login_required
def checkout():
with get_db_connection() as conn:
products = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products').fetchall()
return render_template("checkout.html", active_page='checkout', user=current_user, products=products)
@pos_bp.route('/scan', methods=['GET'])
def scan():
barcode = request.args.get('content', '').replace('{content}', '')
if not barcode:
return jsonify({"status": "error", "message": "empty barcode"}), 400
with get_db_connection() as conn:
p = conn.execute('SELECT barcode, name, price, image_url, stock, unit_type FROM products WHERE barcode = ?', (barcode,)).fetchone()
if p:
barcode_val, name, price, image_path, stock, unit_type = p
if image_path and image_path.startswith('/static/'):
clean_path = image_path.split('?')[0].lstrip('/')
if not os.path.exists(clean_path):
cache_dir = current_app.config['CACHE_DIR']
ext_data = fetch_from_openfoodfacts(barcode_val, cache_dir)
if ext_data and ext_data.get('image'):
image_path = ext_data['image']
with get_db_connection() as conn:
conn.execute('UPDATE products SET image_url = ? WHERE barcode = ?', (image_path, barcode_val))
conn.commit()
product_data = {
"barcode": barcode_val,
"name": name,
"price": int(price),
"image": image_path,
"stock": stock,
"unit_type": unit_type
}
socketio.emit('new_scan', product_data)
return jsonify({"status": "ok", "data": product_data}), 200
cache_dir = current_app.config['CACHE_DIR']
ext = fetch_from_openfoodfacts(barcode, cache_dir)
if ext:
external_data = {
"barcode": barcode,
"name": ext['name'],
"image": ext['image'],
"source": "openfoodfacts"
}
socketio.emit('scan_error', external_data)
return jsonify({"status": "not_found", "data": external_data}), 404
socketio.emit('scan_error', {"barcode": barcode})
return jsonify({"status": "not_found", "data": {"barcode": barcode}}), 404
@pos_bp.route('/api/checkout', methods=['POST'])
@login_required
def process_checkout():
try:
data = request.get_json()
cart = data.get('cart', [])
payment_method = data.get('payment_method', 'efectivo')
if not cart:
return jsonify({"error": "Cart is empty"}), 400
total = sum(item.get('subtotal', 0) for item in cart)
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute('INSERT INTO sales (date, total, payment_method) VALUES (CURRENT_TIMESTAMP, ?, ?)', (total, payment_method))
sale_id = cur.lastrowid
for item in cart:
cur.execute('''INSERT INTO sale_items (sale_id, barcode, name, price, quantity, subtotal)
VALUES (?, ?, ?, ?, ?, ?)''',
(sale_id, item['barcode'], item['name'], item['price'], item['qty'], item['subtotal']))
cur.execute('UPDATE products SET stock = stock - ? WHERE barcode = ?', (item['qty'], item['barcode']))
conn.commit()
return jsonify({"status": "success", "sale_id": sale_id}), 200
except Exception as e:
print(f"Checkout Error: {e}")
return jsonify({"error": str(e)}), 500
@pos_bp.route('/api/scale/weight', methods=['POST'])
def update_scale_weight():
data = request.get_json()
weight_grams = data.get('weight', 0)
weight_kg = round(weight_grams / 1000, 3)
socketio.emit('scale_update', {
"grams": weight_grams,
"kilograms": weight_kg,
"timestamp": time.time()
})
return jsonify({"status": "received"}), 200

147
blueprints/sales.py Normal file
View File

@@ -0,0 +1,147 @@
from flask import Blueprint, render_template, request, jsonify, send_file, current_app
from flask_login import login_required, current_user
from core.db import get_db_connection
import io
import zipfile
from datetime import datetime
import os
sales_bp = Blueprint('sales', __name__)
@sales_bp.route('/export/db')
@login_required
def export_db():
db_file = current_app.config['DB_FILE']
if os.path.exists(db_file):
return send_file(db_file, as_attachment=True, download_name=f"SekiPOS_Backup_{datetime.now().strftime('%Y%m%d')}.db", mimetype='application/x-sqlite3')
return "Error: Database file not found", 404
@sales_bp.route('/export/images')
@login_required
def export_images():
cache_dir = current_app.config['CACHE_DIR']
if not os.path.exists(cache_dir) or not os.listdir(cache_dir):
return "No images found to export", 404
memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(cache_dir):
for file in files:
file_path = os.path.join(root, file)
zf.write(file_path, arcname=file)
memory_file.seek(0)
return send_file(
memory_file,
mimetype='application/zip',
as_attachment=True,
download_name=f"SekiPOS_Images_{datetime.now().strftime('%Y%m%d')}.zip"
)
@sales_bp.route('/sales')
@login_required
def sales():
selected_date = request.args.get('date')
payment_method = request.args.get('payment_method')
page = request.args.get('page', 1, type=int)
per_page = 100
with get_db_connection() as conn:
cur = conn.cursor()
target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0]
daily_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?"
week_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')"
month_query = "SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')"
daily_params = [target_date]
week_params = []
month_params = []
if payment_method:
daily_query += " AND payment_method = ?"
week_query += " AND payment_method = ?"
month_query += " AND payment_method = ?"
daily_params.append(payment_method)
week_params.append(payment_method)
month_params.append(payment_method)
stats = {
"daily": cur.execute(daily_query, tuple(daily_params)).fetchone()[0] or 0,
"week": cur.execute(week_query, tuple(week_params)).fetchone()[0] or 0,
"month": cur.execute(month_query, tuple(month_params)).fetchone()[0] or 0
}
base_query = "FROM sales WHERE 1=1"
params = []
if selected_date:
base_query += " AND date(date, 'localtime') = ?"
params.append(selected_date)
if payment_method:
base_query += " AND payment_method = ?"
params.append(payment_method)
stats_query = f"SELECT COUNT(*), SUM(total) {base_query}"
count_res, sum_res = cur.execute(stats_query, tuple(params)).fetchone()
total_count = count_res or 0
total_sum = sum_res or 0
total_pages = (total_count + per_page - 1) // per_page
filtered_stats = {
"total": total_sum,
"count": total_count
}
offset = (page - 1) * per_page
data_query = f"SELECT id, date, total, payment_method {base_query} ORDER BY date DESC LIMIT ? OFFSET ?"
sales_data = cur.execute(data_query, tuple(params) + (per_page, offset)).fetchall()
return render_template('sales.html',
active_page='sales',
user=current_user,
sales=sales_data,
stats=stats,
filtered_stats=filtered_stats,
selected_date=selected_date,
selected_payment=payment_method,
current_page=page,
total_pages=total_pages)
@sales_bp.route('/api/sale/<int:sale_id>')
@login_required
def get_sale_details(sale_id):
with get_db_connection() as conn:
items = conn.execute('SELECT barcode, name, price, quantity, subtotal FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall()
item_list = [{"barcode": i[0], "name": i[1], "price": i[2], "qty": i[3], "subtotal": i[4]} for i in items]
return jsonify(item_list), 200
@sales_bp.route('/api/sale/<int:sale_id>', methods=['DELETE'])
@login_required
def reverse_sale(sale_id):
try:
with get_db_connection() as conn:
cur = conn.cursor()
items = cur.execute('SELECT barcode, quantity FROM sale_items WHERE sale_id = ?', (sale_id,)).fetchall()
for barcode, qty in items:
cur.execute('UPDATE products SET stock = stock + ? WHERE barcode = ?', (qty, barcode))
cur.execute('DELETE FROM sale_items WHERE sale_id = ?', (sale_id,))
cur.execute('DELETE FROM sales WHERE id = ?', (sale_id,))
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Reverse Sale Error: {e}")
return jsonify({"error": str(e)}), 500

2
core/__pycache__/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

79
core/db.py Normal file
View File

@@ -0,0 +1,79 @@
import sqlite3
_db_file = None
def init_db(db_file):
global _db_file
_db_file = db_file
with get_db_connection() as conn:
conn.execute('''CREATE TABLE IF NOT EXISTS users
(id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS products
(barcode TEXT PRIMARY KEY,
name TEXT,
price REAL,
image_url TEXT,
stock REAL DEFAULT 0,
unit_type TEXT DEFAULT 'unit')''')
conn.execute('''CREATE TABLE IF NOT EXISTS sales
(id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT DEFAULT CURRENT_TIMESTAMP,
total REAL,
payment_method TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS sale_items
(id INTEGER PRIMARY KEY AUTOINCREMENT,
sale_id INTEGER,
barcode TEXT,
name TEXT,
price REAL,
quantity REAL,
subtotal REAL,
FOREIGN KEY(sale_id) REFERENCES sales(id))''')
conn.execute('''CREATE TABLE IF NOT EXISTS dicom
(id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
amount REAL DEFAULT 0,
notes TEXT,
image_url TEXT,
last_updated TEXT DEFAULT CURRENT_TIMESTAMP)''')
conn.execute('''CREATE TABLE IF NOT EXISTS debtors
(id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
contact_info TEXT)''')
conn.execute('''CREATE TABLE IF NOT EXISTS debtor_tickets
(id INTEGER PRIMARY KEY AUTOINCREMENT,
debtor_id INTEGER NOT NULL,
date TEXT DEFAULT CURRENT_TIMESTAMP,
total REAL NOT NULL,
amount_paid REAL DEFAULT 0,
status TEXT DEFAULT 'unpaid',
FOREIGN KEY(debtor_id) REFERENCES debtors(id) ON DELETE CASCADE)''')
conn.execute('''CREATE TABLE IF NOT EXISTS debtor_ticket_items
(id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
barcode TEXT,
name TEXT,
price REAL,
quantity REAL,
subtotal REAL,
FOREIGN KEY(ticket_id) REFERENCES debtor_tickets(id) ON DELETE CASCADE)''')
user = conn.execute('SELECT * FROM users WHERE username = ?', ('admin',)).fetchone()
if not user:
from werkzeug.security import generate_password_hash
hashed_pw = generate_password_hash('choripan1234')
conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', ('admin', hashed_pw))
conn.commit()
def get_db_connection():
if _db_file is None:
raise RuntimeError("Database not initialized. Call init_db(db_file) first.")
return sqlite3.connect(_db_file)

2
core/db/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

3
core/events.py Normal file
View File

@@ -0,0 +1,3 @@
from flask_socketio import SocketIO
socketio = SocketIO()

23
core/openfood.py Normal file
View File

@@ -0,0 +1,23 @@
import requests
from core.utils import download_image
def fetch_from_openfoodfacts(barcode, cache_dir):
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
try:
headers = {'User-Agent': 'SekiPOS/1.0'}
resp = requests.get(url, headers=headers, timeout=5).json()
if resp.get('status') == 1:
p = resp.get('product', {})
name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown')
imgs = p.get('selected_images', {}).get('front', {}).get('display', {})
img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '')
if img_url:
local_img = download_image(img_url, barcode, cache_dir)
return {"name": name, "image": local_img}
return {"name": name, "image": None}
except Exception as e:
print(f"API Error: {e}")
return None

46
core/utils.py Normal file
View File

@@ -0,0 +1,46 @@
import os
import sys
import mimetypes
import requests
def get_bundled_path(relative_path):
"""Path for read-only files packed inside the .exe (templates, static)"""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base_path = project_root
return os.path.join(base_path, relative_path)
def get_persistent_path(relative_path):
"""Path for read/write files that must survive reboots (db, cache)"""
if getattr(sys, 'frozen', False):
base_path = os.path.dirname(sys.executable)
else:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base_path = project_root
return os.path.join(base_path, relative_path)
def download_image(url, barcode, cache_dir):
if not url or not url.startswith('http'):
return url
try:
headers = {'User-Agent': 'SekiPOS/1.2'}
with requests.get(url, headers=headers, stream=True, timeout=5) as r:
r.raise_for_status()
content_type = r.headers.get('content-type')
ext = mimetypes.guess_extension(content_type) or '.jpg'
local_filename = f"{barcode}{ext}"
local_path = os.path.join(cache_dir, local_filename)
with open(local_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return f"/static/cache/{local_filename}"
except Exception as e:
print(f"Download failed: {e}")
return url

View File

@@ -12,6 +12,18 @@
--danger: #ed4245;
}
html, body {
background-color: var(--bg);
transition: background-color 0.15s ease, color 0.15s ease;
}
/* Prevent theme flash on initial load */
html[data-theme="dark"] body,
html[data-theme="dark"] {
background-color: #36393f;
color: #dcddde;
}
[data-theme="dark"] {
--bg: #36393f;
--card-bg: #2f3136;

View File

@@ -312,7 +312,104 @@
</div>
</div>
<!-- Dicom Checkout Modal -->
<div class="modal fade" id="dicomModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Mandar a Dicom</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label text-muted small mb-1">Seleccionar Deudor</label>
<div class="input-group">
<select id="dicom-debtor-select" class="form-select" onchange="toggleNewDebtorInput()">
<option value="">-- Seleccionar deudor --</option>
</select>
<button class="btn btn-success" type="button" onclick="showNewDebtorInput()" title="Nuevo deudor">
<i class="bi bi-plus-lg"></i> Nuevo
</button>
</div>
<input type="text" id="dicom-debtor-name" class="form-control mt-2" placeholder="Nombre del nuevo deudor..." style="display:none;">
<small id="debtor-count" class="text-muted"></small>
</div>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Información de Contacto (Opcional)</label>
<input type="text" id="dicom-contact-info" class="form-control" placeholder="Teléfono, dirección...">
</div>
<div class="p-3 rounded mb-2" style="background: var(--input-bg);">
<div class="d-flex justify-content-between">
<span class="text-muted">Total a Dicom:</span>
<span id="dicom-total" class="fw-bold" style="color: var(--danger); font-size: 1.2rem;">$0</span>
</div>
</div>
<div class="mb-2">
<label class="form-label fw-bold small mb-1" style="color: #198754;">
<i class="bi bi-cash-coin me-1"></i>Pago inicial (opcional)
</label>
<div class="input-group">
<span class="input-group-text" style="background: #198754; border-color: #198754; color: #fff;">$</span>
<input type="text" id="dicom-initial-payment" class="form-control fw-bold border-start-0" style="border-color: #198754;" placeholder="0" oninput="formatDicomPayment(this)">
</div>
</div>
<div class="p-2 rounded" style="background: var(--input-bg);">
<div class="d-flex justify-content-between">
<span class="text-muted">Saldo pendiente:</span>
<span id="dicom-remaining" class="fw-bold" style="color: #198754;">$0</span>
</div>
</div>
</div>
<div class="modal-footer d-flex">
<button class="btn btn-secondary flex-grow-1" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-danger flex-grow-1" onclick="processDicomCheckout()">
<i class="bi bi-send me-1"></i>Enviar a Dicom
</button>
</div>
</div>
</div>
</div>
<!-- Dicom Success Modal -->
<div class="modal fade" id="dicomSuccessModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title"><i class="bi bi-check-circle me-2"></i>Enviado a Dicom</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center py-4">
<div class="mb-3">
<i class="bi bi-receipt" style="font-size: 3rem; color: var(--success);"></i>
</div>
<h6 class="mb-2">Ticket #<span id="dicom-success-ticket-id"></span></h6>
<p class="text-muted mb-0">Registrado para</p>
<h5 class="text-success mb-0" id="dicom-success-debtor"></h5>
</div>
<div class="modal-footer d-flex">
<button class="btn btn-success flex-grow-1" data-bs-dismiss="modal">Aceptar</button>
</div>
</div>
</div>
</div>
<div class="container-fluid">
<!-- Kitchen Ticket Print Zone -->
<div id="kitchen-print-zone" class="d-none d-print-block">
<style>
@media print {
@page { size: 80mm auto; margin: 0; }
nav, .discord-card, .modal, .row, #kitchen-print-zone { display: none !important; }
#kitchen-print-zone, #kitchen-print-zone * { visibility: visible; }
#kitchen-print-zone {
position: absolute; left: 0; top: 0; width: 80mm;
padding: 5mm; display: block !important;
}
}
</style>
<div id="kitchen-ticket-content"></div>
</div>
<div class="row g-3">
<div class="col-md-8">
<div class="discord-card p-3">
@@ -359,11 +456,44 @@
</div>
<div class="col-md-4">
<!-- Restaurant Mode Panel -->
<div id="restaurant-panel" class="discord-card p-3 mb-3 d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><i class="bi bi-receipt me-2"></i>Comanda</h6>
<span class="badge bg-accent">Modo Comida</span>
</div>
<div class="mb-2">
<label class="form-label text-muted small mb-1">Nombre del Cliente</label>
<input type="text" id="restaurant-client-name" class="form-control" placeholder="Ej: Juan" autocomplete="off">
</div>
<div class="mb-2">
<label class="form-label text-muted small mb-1">Tipo</label>
<select id="restaurant-order-type" class="form-select">
<option value="servir">Para Servir</option>
<option value="llevar">Para Llevar</option>
</select>
</div>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Notas Adicionales</label>
<input type="text" id="restaurant-notes" class="form-control" placeholder="Sin cebolla, extra salsa..." autocomplete="off">
</div>
<div class="d-flex gap-2">
<button class="btn btn-accent flex-grow-1" onclick="printKitchenTicket()">
<i class="bi bi-printer me-1"></i>Imprimir Comanda
</button>
<button id="btn-reset-comanda" class="btn btn-outline-secondary d-none" onclick="resetKitchenTicket()">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="discord-card p-3 mb-3 text-center shadow-sm">
<p class="mb-1 fw-semibold text-uppercase" style="color:var(--text-muted); font-size:0.7rem">Último Escaneado</p>
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
<h6 id="display-name" class="mb-0 text-truncate">Esperando scan...</h6>
<small id="display-barcode" class="text-muted font-monospace" style="font-size: 0.7rem"></small>
<div id="last-scanned-content">
<p class="mb-1 fw-semibold text-uppercase" style="color:var(--text-muted); font-size:0.7rem">Último Escaneado</p>
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
<h6 id="display-name" class="mb-0 text-truncate">Esperando scan...</h6>
<small id="display-barcode" class="text-muted font-monospace" style="font-size: 0.7rem"></small>
</div>
<div class="total-banner text-center mb-3 mt-3">
<h2 class="mb-0">TOTAL</h2>
@@ -379,6 +509,9 @@
<button class="btn btn-danger w-100 btn-lg" onclick="clearCart()">
<i class="bi bi-trash3"></i> VACIAR
</button>
<button class="btn btn-outline-danger w-100 btn-lg mt-2" id="btn-mandar-dicom" onclick="openDicomModal()">
<i class="bi bi-person-plus"></i> Mandar a Dicom
</button>
</div>
</div>
</div>
@@ -390,7 +523,12 @@
/* =========================================
1. GLOBAL STATE & FORMATTERS
========================================= */
let cart = [];
let cart = JSON.parse(localStorage.getItem('seki_cart') || '[]');
function saveCart() {
localStorage.setItem('seki_cart', JSON.stringify(cart));
}
let pendingProduct = null;
let missingProductData = null;
let tempBarcode = null;
@@ -407,6 +545,18 @@
// Fetch the pinned items from local storage
let pinnedBarcodes = JSON.parse(localStorage.getItem('seki_pinned_products')) || [];
// Restaurant Mode (Modo Comida) initialization
const modoComida = localStorage.getItem('modo_comida') === 'true';
if (modoComida) {
document.getElementById('restaurant-panel').classList.remove('d-none');
}
// Last Scanned panel toggle
const showLastScanned = localStorage.getItem('seki_last_scanned') !== 'false';
if (!showLastScanned) {
document.getElementById('last-scanned-content').classList.add('d-none');
}
let socket = io();
const clp = new Intl.NumberFormat('es-CL', {
@@ -491,6 +641,16 @@
document.getElementById('grand-total').innerText = clp.format(total);
saveCart();
// Enable/disable Mandar a Dicom button
const dicomBtn = document.getElementById('btn-mandar-dicom');
if (cart.length === 0) {
dicomBtn.classList.add('disabled');
dicomBtn.setAttribute('disabled', 'true');
} else {
dicomBtn.classList.remove('disabled');
dicomBtn.removeAttribute('disabled');
}
}
function addToCart(product, qty) {
@@ -499,8 +659,9 @@
cart[existingIndex].qty += qty;
cart[existingIndex].subtotal = calculateSubtotal(cart[existingIndex].price, cart[existingIndex].qty);
} else {
cart.push({ ...product, qty, subtotal: calculateSubtotal(product.price, qty) });
cart.push({ ...product, qty, subtotal: calculateSubtotal(product.price, qty), printed_qty: 0 });
}
saveCart();
renderCart();
}
@@ -511,6 +672,7 @@
removeItem(index, cart[index].name);
} else {
cart[index].subtotal = calculateSubtotal(cart[index].price, cart[index].qty);
saveCart();
renderCart();
}
}
@@ -520,6 +682,7 @@
if (isNaN(newQty) || newQty <= 0) return;
cart[index].qty = newQty;
cart[index].subtotal = calculateSubtotal(cart[index].price, cart[index].qty);
saveCart();
renderCart();
}
@@ -532,6 +695,7 @@
function executeRemoveItem() {
if (itemIndexToRemove !== null) {
cart.splice(itemIndexToRemove, 1);
saveCart();
renderCart();
bootstrap.Modal.getInstance(document.getElementById('removeConfirmModal')).hide();
itemIndexToRemove = null;
@@ -545,6 +709,7 @@
function executeClearCart() {
cart = [];
saveCart();
renderCart();
clearLastScanned();
bootstrap.Modal.getInstance(document.getElementById('clearCartModal')).hide();
@@ -558,7 +723,16 @@
function loadCart() {
const saved = localStorage.getItem('seki_cart');
if (saved) {
try { cart = JSON.parse(saved); renderCart(); }
try {
cart = JSON.parse(saved);
// Ensure all items have printed_qty property
cart.forEach(item => {
if (typeof item.printed_qty === 'undefined') {
item.printed_qty = 0;
}
});
renderCart();
}
catch (e) { console.error(e); cart = []; }
}
}
@@ -714,7 +888,8 @@
price: priceInput,
image: '',
stock: 0,
unit: unitInput
unit: unitInput,
printed_qty: 0
};
if (unitInput === 'kg') {
@@ -751,7 +926,8 @@
subtotal: price,
image: '',
stock: 0,
unit: 'unit'
unit: 'unit',
printed_qty: 0
}, 1);
bootstrap.Modal.getInstance(document.getElementById('variosModal')).hide();
}
@@ -918,6 +1094,7 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal')).show();
cart = [];
saveCart();
renderCart();
clearLastScanned();
setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000);
@@ -960,7 +1137,7 @@
const quickCart = [{
barcode: `RAPIDA-${Date.now().toString().slice(-6)}`,
name: '* Varios', price: amount, qty: 1, subtotal: amount, unit: 'unit'
name: '* Varios', price: amount, qty: 1, subtotal: amount, unit: 'unit', printed_qty: 0
}];
try {
@@ -992,6 +1169,262 @@
}
}
/* =========================================
RESTAURANT MODE (MODO COMIDA)
========================================= */
function printKitchenTicket() {
const clientName = document.getElementById('restaurant-client-name').value.trim();
const orderType = document.getElementById('restaurant-order-type').value;
const notes = document.getElementById('restaurant-notes').value.trim();
// Calculate delta items (qty - printed_qty)
const deltaItems = cart.filter(item => {
const printed = item.printed_qty || 0;
return item.qty > printed;
}).map(item => ({
...item,
delta: item.qty - (item.printed_qty || 0)
}));
if (deltaItems.length === 0) {
alert('No hay items nuevos para imprimir.');
return;
}
// Update printed_qty for delta items
cart.forEach(item => {
if (item.qty > (item.printed_qty || 0)) {
item.printed_qty = item.qty;
}
});
// Show reset button if anything has been printed
const hasPrinted = cart.some(item => (item.printed_qty || 0) > 0);
document.getElementById('btn-reset-comanda').classList.toggle('d-none', !hasPrinted);
// Get comanda size setting
const comandaSize = localStorage.getItem('seki_comanda_size') || 'medium';
const sizeMap = {
small: { header: '14px', title: '16px', item: '12px', qty: '12px' },
medium: { header: '16px', title: '20px', item: '14px', qty: '14px' },
large: { header: '18px', title: '24px', item: '18px', qty: '18px' },
xlarge: { header: '20px', title: '28px', item: '22px', qty: '22px' }
};
const sizes = sizeMap[comandaSize];
// Build the kitchen ticket HTML
const ticketHtml = `
<div style="font-family: 'Courier New', monospace; padding: 10px; font-size: ${sizes.header};">
<div style="text-align: center; border-bottom: 2px dashed #000; padding-bottom: 10px; margin-bottom: 10px;">
<strong style="font-size: ${sizes.title};">COMANDA</strong><br>
${clientName ? `<span>Cliente: ${clientName}</span><br>` : ''}
<span>${orderType === 'servir' ? '🍽️ PARA SERVIR' : '🥡 PARA LLEVAR'}</span>
${notes ? `<br><em>Nota: ${notes}</em>` : ''}
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px dashed #000;">
<th style="text-align: left; padding: 3px 0;">Cant</th>
<th style="text-align: left; padding: 3px 0;">Producto</th>
</tr>
</thead>
<tbody>
${deltaItems.map(item => `
<tr>
<td style="padding: 2px 0; font-size: ${sizes.qty};"><strong>${item.unit === 'kg' ? item.delta.toFixed(3) : item.delta}</strong></td>
<td style="padding: 2px 0; font-size: ${sizes.item};">${item.name}</td>
</tr>
`).join('')}
</tbody>
</table>
<div style="text-align: center; margin-top: 15px; font-size: 11px;">
${new Date().toLocaleString('es-CL')}
</div>
</div>
`;
// Create a temporary print window
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Comanda - ${clientName || 'Sin nombre'}</title>
<style>
@media print {
body { margin: 0; padding: 0; }
@page { size: 80mm auto; margin: 0; }
}
</style>
</head>
<body>${ticketHtml}</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 250);
}
function resetKitchenTicket() {
// Reset printed_qty for all items
cart.forEach(item => {
item.printed_qty = 0;
});
// Clear inputs
document.getElementById('restaurant-client-name').value = '';
document.getElementById('restaurant-notes').value = '';
document.getElementById('restaurant-order-type').value = 'servir';
// Hide reset button
document.getElementById('btn-reset-comanda').classList.add('d-none');
}
/* =========================================
DICOM CHECKOUT
========================================= */
function openDicomModal() {
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
document.getElementById('dicom-total').innerText = clp.format(total);
document.getElementById('dicom-remaining').innerText = clp.format(total);
document.getElementById('dicom-debtor-name').value = '';
document.getElementById('dicom-contact-info').value = '';
document.getElementById('dicom-initial-payment').value = '';
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomModal')).show();
fetchDebtorsList();
}
// Format payment input with dots
function formatDicomPayment(input) {
let value = input.value.replace(/\./g, '').replace(/[^0-9]/g, '');
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
if (value && parseInt(value) > total) {
value = total.toString();
}
if (value) {
value = parseInt(value).toLocaleString('es-CL');
}
input.value = value;
// Update remaining
const rawValue = parseInt(input.value.replace(/\./g, '')) || 0;
const remaining = Math.max(0, total - rawValue);
document.getElementById('dicom-remaining').innerText = clp.format(remaining);
}
// Update remaining when initial payment changes
document.getElementById('dicom-initial-payment').addEventListener('input', function() {
formatDicomPayment(this);
});
// Load when modal is fully shown
document.getElementById('dicomModal').addEventListener('shown.bs.modal', function() {
fetchDebtorsList();
});
async function fetchDebtorsList() {
try {
const res = await fetch('/api/dicom/debtors', { credentials: 'same-origin' });
if (!res.ok) return;
const debtors = await res.json();
const select = document.getElementById('dicom-debtor-select');
const countLabel = document.getElementById('debtor-count');
select.innerHTML = '<option value="">-- Seleccionar deudor --</option>';
if (debtors && debtors.length > 0) {
for (let d of debtors) {
const opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.contact_info ? `${d.name} - ${d.contact_info}` : d.name;
select.appendChild(opt);
}
countLabel.textContent = `${debtors.length} deudor(es)`;
} else {
countLabel.textContent = 'No hay deudores';
}
} catch (e) {
console.error('Error loading debtors:', e);
}
}
function showNewDebtorInput() {
document.getElementById('dicom-debtor-select').value = '';
document.getElementById('dicom-debtor-name').style.display = 'block';
document.getElementById('dicom-debtor-name').focus();
}
function toggleNewDebtorInput() {
const select = document.getElementById('dicom-debtor-select');
const nameInput = document.getElementById('dicom-debtor-name');
if (select.value === '') {
nameInput.style.display = 'block';
} else {
nameInput.style.display = 'none';
nameInput.value = '';
}
}
async function processDicomCheckout() {
// Check if selecting existing or entering new
const select = document.getElementById('dicom-debtor-select');
const nameInput = document.getElementById('dicom-debtor-name');
let debtorName = select.value || nameInput.value.trim();
const contactInfo = document.getElementById('dicom-contact-info').value.trim();
const paymentInput = document.getElementById('dicom-initial-payment').value.replace(/\./g, '');
const initialPayment = parseInt(paymentInput) || 0;
if (!debtorName) {
alert('Por favor ingresa el nombre del deudor.');
return;
}
try {
const res = await fetch('/api/dicom/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
cart: cart,
debtor_name: debtorName,
contact_info: contactInfo,
initial_payment: initialPayment
})
});
const result = await res.json();
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('dicomModal')).hide();
document.getElementById('dicom-success-ticket-id').textContent = result.ticket_id;
document.getElementById('dicom-success-debtor').textContent = result.debtor;
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomSuccessModal')).show();
// Clear cart
cart = [];
saveCart();
renderCart();
clearLastScanned();
} else {
alert('Error: ' + (result.error || 'Error desconocido'));
}
} catch (e) {
console.error(e);
alert('Error de conexión.');
}
}
function printReceipt(total, saleId, paidAmount = 0) {
const tbody = document.getElementById('receipt-items-print');
tbody.innerHTML = '';

View File

@@ -1,155 +1,188 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal %}
{% block title %}Dicom{% endblock %}
{% block content %}
<style>
.debtor-item {
background: var(--card-bg);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
transition: all 0.2s ease;
border: 1px solid rgba(255,255,255,0.05);
}
.debtor-item:hover {
background: var(--input-bg);
border-color: rgba(255,255,255,0.1);
}
.debtor-item .debtor-avatar {
width: 48px;
height: 48px;
min-width: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
}
.debtor-name {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-main);
}
.debtor-contact {
font-size: 0.85rem;
color: var(--text-muted);
}
.debtor-debt {
font-size: 1.3rem;
font-weight: 700;
color: var(--danger);
}
.debtor-debt.paid {
color: var(--success);
}
.ticket-card {
background: var(--card-bg);
border-radius: 10px;
padding: 16px;
margin-bottom: 10px;
border: 1px solid rgba(255,255,255,0.05);
transition: all 0.2s ease;
}
.ticket-card:hover {
border-color: rgba(255,255,255,0.15);
}
.ticket-status {
font-size: 0.75rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ticket-status.paid { background: rgba(40, 167, 69, 0.2); color: #28a745; }
.ticket-status.partial { background: rgba(255, 193, 7, 0.2); color: #ffc107; }
.ticket-status.unpaid { background: rgba(220, 53, 69, 0.2); color: #dc3545; }
.ticket-item-row {
padding: 6px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.ticket-item-row:last-child { border-bottom: none; }
.chevron-icon {
transition: transform 0.3s ease;
font-size: 1.1rem;
}
.chevron-icon.rotated {
transform: rotate(180deg);
}
.btn-pay-all {
font-size: 0.8rem;
padding: 6px 14px;
border-radius: 8px;
font-weight: 600;
}
.btn-delete-debtor {
width: 36px;
height: 36px;
padding: 0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state i {
font-size: 3rem;
opacity: 0.3;
}
</style>
<div class="row g-3">
<div class="col-12">
<div class="discord-card p-3">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<div class="position-relative flex-grow-1">
<input type="text" id="dicom-search" class="form-control ps-5 py-2"
placeholder="Buscar cliente por nombre..." onkeyup="filterDicom()">
<i class="bi bi-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted"></i>
</div>
<button class="btn btn-accent text-nowrap py-2 px-3 fw-bold" onclick="openNewModal()">
<i class="bi bi-plus-lg me-1"></i> Nuevo Deudor
</button>
<div class="discord-card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-people me-2"></i>Deudores</h4>
<span class="badge bg-secondary">{{ debtors|length }} registrados</span>
</div>
<div class="table-responsive">
<table class="table mb-0" id="dicom-table">
<thead>
<tr>
<th style="width: 60px;">Foto</th>
<th>Nombre</th>
<th>Deuda Total</th>
<th>Última Nota</th>
<th>Actualizado</th>
<th class="text-end">Acciones</th>
</tr>
</thead>
<tbody>
{% for d in debtors %}
<tr>
<td>
{% if d[5] %}
<img src="{{ d[5] }}" class="rounded shadow-sm" style="width: 40px; height: 40px; object-fit: cover; cursor: pointer;"
onclick="window.open(this.src, '_blank')">
{% else %}
<div class="bg-secondary rounded" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; opacity: 0.3;">
<i class="bi bi-person text-white"></i>
</div>
{% endif %}
</td>
<td class="fw-bold align-middle">{{ d[1] }}</td>
<td class="fw-bold price-cell align-middle fs-5" data-value="{{ d[2] }}"></td>
<td class="text-muted small align-middle">{{ d[3] }}</td>
<td class="text-muted small align-middle">{{ d[4] }}</td>
<td class="text-end align-middle">
<button class="btn btn-sm btn-outline-secondary" onclick="openQuickEditModal('{{ d[1] }}', '{{ d[3] }}')"
title="Actualizar Deuda">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger ms-1"
onclick="forgiveDebt({{ d[0] }}, '{{ d[1] }}')" title="Eliminar Registro">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="debtors-list">
{% for d in debtors %}
<div class="debtor-item" data-debtor-id="{{ d[0] }}">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3 flex-grow-1 cursor-pointer" onclick="toggleDebtor({{ d[0] }})">
<div class="debtor-avatar {% if d[3] > 0 %}bg-danger{% else %}bg-success{% endif %} text-white">
<i class="bi bi-person-fill"></i>
</div>
<div class="flex-grow-1">
<div class="debtor-name">{{ d[1] }}</div>
<div class="debtor-contact">{{ d[2] or 'Sin contacto' }}</div>
</div>
</div>
<div class="text-end me-3">
<div class="debtor-debt {% if d[3] <= 0 %}paid{% endif %} price-cell" data-value="{{ d[3] }}"></div>
<small class="text-muted">{% if d[3] > 0 %}Deuda pendiente{% else %}Saldo cero{% endif %}</small>
</div>
<div class="d-flex align-items-center gap-2">
{% if d[3] > 0 %}
<button class="btn btn-sm btn-success btn-pay-all" onclick="payAllDebtor({{ d[0] }}, {{ d[3] }})" title="Pagar toda la deuda">
<i class="bi bi-check-lg me-1"></i>Pagar Todo
</button>
{% endif %}
<button class="btn btn-sm btn-outline-danger btn-delete-debtor" onclick="deleteDebtor({{ d[0] }}, '{{ d[1] }}')" title="Eliminar deudor">
<i class="bi bi-trash"></i>
</button>
<i class="bi bi-chevron-down text-muted chevron-icon" id="chevron-{{ d[0] }}"></i>
</div>
</div>
<!-- Expanded tickets view -->
<div id="debtor-{{ d[0] }}" class="d-none mt-3 pt-3" style="border-top: 1px solid rgba(255,255,255,0.05);">
<div id="tickets-container-{{ d[0] }}" class="text-center text-muted py-3">
<span class="spinner-border spinner-border-sm me-2"></span>Cargando tickets...
</div>
</div>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-emoji-frown d-block mb-3"></i>
<p class="mb-0">No hay deudores registrados</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="modal fade" id="dicomModal" tabindex="-1" data-bs-backdrop="static">
<!-- Payment Modal -->
<div class="modal fade" id="paymentModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem;" id="dicomModalTitle">
Registrar Movimiento
</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2 pb-4">
<div class="mb-3">
<label class="small text-muted mb-1">Nombre del Cliente</label>
<input type="text" id="dicom-name" class="form-control form-control-lg fw-bold" placeholder="Ej: Doña Juanita">
</div>
<div class="mb-3">
<label class="small text-muted mb-1">Monto (CLP)</label>
<input type="number" id="dicom-amount" class="form-control form-control-lg text-center fw-bold fs-4" placeholder="$0">
</div>
<div class="mb-3">
<label class="small text-muted mb-1">Nota (Opcional)</label>
<input type="text" id="dicom-notes" class="form-control" placeholder="Ej: Pan y bebida"
onkeydown="if(event.key === 'Enter') submitDicom('add')">
</div>
<div class="mb-4">
<label class="small text-muted mb-1">Foto / Comprobante (Opcional)</label>
<div class="input-group">
<input type="text" id="dicom-image-url" class="form-control" placeholder="URL de imagen" readonly>
<input type="file" id="dicom-camera-input" accept="image/*" capture="environment" style="display: none;"
onchange="handleDicomUpload(this)">
<button class="btn btn-outline-secondary" type="button"
onclick="document.getElementById('dicom-camera-input').click()">
<i class="bi bi-camera"></i>
</button>
</div>
<div id="dicom-img-preview-container" class="mt-2 text-center d-none">
<img id="dicom-img-preview" src="" class="img-thumbnail rounded" style="max-height: 120px; border-color: var(--border);">
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-danger flex-grow-1 py-3 fw-bold" onclick="submitDicom('add')">
<i class="bi bi-cart-plus me-1"></i> Fiar
</button>
<button class="btn btn-success flex-grow-1 py-3 fw-bold" onclick="submitDicom('pay')">
<i class="bi bi-cash-coin me-1"></i> Abonar
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="quickEditModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem;">
Actualizar Deuda
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">
Pagar Deuda
</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<h4 id="quick-edit-name" class="mb-3 fw-bold" style="color: var(--text-main);"></h4>
<div class="mb-3 text-start">
<label class="text-muted small mb-1">Monto a agregar/restar (CLP)</label>
<input type="number" id="quick-edit-amount" class="form-control form-control-lg text-center fw-bold fs-4" placeholder="$0"
onkeydown="if(event.key === 'Enter') submitQuickEdit('add')">
<div class="mb-4">
<span class="text-muted small">Total a Pagar:</span><br>
<span id="payment-remaining-display" class="fs-1 fw-bold" style="color: var(--accent)">$0</span>
</div>
<div class="mb-4 text-start">
<label class="text-muted small mb-1">Nota Actualizada</label>
<input type="text" id="quick-edit-notes" class="form-control" placeholder="Ej: Pan y bebida"
onkeydown="if(event.key === 'Enter') submitQuickEdit('add')">
</div>
<div class="d-flex gap-2">
<button class="btn btn-danger flex-grow-1 py-2 fw-bold" onclick="submitQuickEdit('add')">
<i class="bi bi-cart-plus me-1"></i> Fiar
<div class="d-grid gap-3 px-3">
<button class="btn btn-lg btn-success py-3" onclick="confirmPayment('efectivo')">
<i class="bi bi-cash-coin me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Efectivo (1)
</button>
<button class="btn btn-success flex-grow-1 py-2 fw-bold" onclick="submitQuickEdit('pay')">
<i class="bi bi-cash-coin me-1"></i> Abonar
<button class="btn btn-lg btn-secondary py-3" onclick="confirmPayment('tarjeta')">
<i class="bi bi-credit-card me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Tarjeta (2)
</button>
<button class="btn btn-lg btn-info py-3 text-white" onclick="confirmPayment('transferencia')">
<i class="bi bi-bank me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Transferencia (3)
</button>
</div>
</div>
@@ -157,222 +190,400 @@
</div>
</div>
{% call confirm_modal('deleteDebtModal', 'Eliminar Registro', 'btn-danger', 'Eliminar Permanente', 'executeForgiveDebt()') %}
<div class="text-center">
<i class="bi bi-exclamation-triangle-fill text-danger mb-3" style="font-size: 3rem;"></i>
<p class="mb-1">¿Estás seguro de que quieres perdonar la deuda y eliminar completamente a <strong id="deleteDebtName" style="color: var(--text-main);"></strong> del registro?</p>
<p class="text-muted small">Esta acción no se puede deshacer.</p>
<!-- Vuelto Modal -->
<div class="modal" id="dicomVueltoModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">Pago en Efectivo</h5>
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-1 pb-4">
<div class="mb-3">
<span class="text-muted small">Total a Pagar:</span><br>
<span id="dicom-vuelto-total" class="fs-4 fw-bold" style="color: var(--text-main)">$0</span>
</div>
<div class="mb-3 text-start">
<label class="text-muted small mb-1">Monto Recibido</label>
<input type="text" inputmode="numeric" id="dicom-monto-recibido" class="form-control form-control-lg text-center fw-bold fs-4"
placeholder="$0"
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : ''; calculateDicomVuelto();">
</div>
<div class="d-flex flex-wrap justify-content-center gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(1000)">$1.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(2000)">$2.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(5000)">$5.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(10000)">$10.000</button>
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(20000)">$20.000</button>
</div>
<div class="p-3 mb-3" style="background: var(--input-bg); border-radius: 8px">
<span class="text-muted small text-uppercase fw-bold">Vuelto a Entregar</span><br>
<span id="dicom-vuelto-amount" class="fs-1 fw-bold text-muted">$0</span>
</div>
<button id="btn-confirm-dicom-vuelto" class="btn btn-success w-100 py-3 fw-bold" onclick="confirmDicomPayment()" disabled>Confirmar Pago</button>
</div>
</div>
</div>
{% endcall %}
</div>
<!-- Success Modal -->
<div class="modal fade" id="dicomSuccessModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-success">
<div class="modal-body text-center py-4">
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
<h4 class="mt-3">¡Pago Exitoso!</h4>
<p class="text-muted">El pago se ha procesado correctamente.</p>
<button class="btn btn-accent px-5" data-bs-dismiss="modal">Listo</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="dicomDeleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header pb-0 border-0">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-0 pb-4">
<i class="bi bi-exclamation-triangle-fill text-danger mb-3" style="font-size: 3rem;"></i>
<h4 class="mb-3" id="dicom-delete-title">¿Eliminar?</h4>
<p class="text-muted small px-3" id="dicom-delete-desc">Esta acción no se puede deshacer.</p>
<div class="d-flex gap-2 justify-content-center mt-4 px-3">
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-danger w-50" id="dicom-delete-confirm-btn">Eliminar</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
let currentDebtorId = null;
let currentTicketId = null;
let expandedDebtorId = null;
let payAllMode = false;
let pendingPaymentMethod = null;
let pendingPaymentAmount = 0;
let deleteCallback = null;
// Format debts and flip colors (Negative = Debt/Red, Positive = Credit/Green)
document.querySelectorAll('.price-cell').forEach(td => {
const val = parseFloat(td.getAttribute('data-value'));
td.innerText = clp.format(val);
if (val < 0) {
td.classList.add('text-danger');
} else if (val > 0) {
td.classList.add('text-success');
} else {
td.classList.add('text-muted');
}
});
// Modal Control Functions
function openNewModal() {
clearDicomForm();
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomModal')).show();
setTimeout(() => document.getElementById('dicom-name').focus(), 500);
}
let currentQuickEditName = '';
function openQuickEditModal(name, notes) {
currentQuickEditName = name;
document.getElementById('quick-edit-name').innerText = name;
document.getElementById('quick-edit-amount').value = '';
// Inject the existing note directly into the new input field
document.getElementById('quick-edit-notes').value = notes || '';
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('quickEditModal'));
modal.show();
setTimeout(() => document.getElementById('quick-edit-amount').focus(), 500);
}
async function submitQuickEdit(action) {
const amount = document.getElementById('quick-edit-amount').value;
const notes = document.getElementById('quick-edit-notes').value; // Grab the newly edited note
if (!amount || amount <= 0 || isNaN(amount)) {
alert('Ingresa un monto válido mayor a 0.');
return;
}
try {
const res = await fetch('/api/dicom/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: currentQuickEditName,
amount: amount,
notes: notes, // Send the UI value instead of the background variable
action: action,
image_url: ''
})
});
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('quickEditModal')).hide();
window.location.reload();
} else {
alert("Error al actualizar la deuda.");
const deleteConfirmBtn = document.getElementById('dicom-delete-confirm-btn');
if (deleteConfirmBtn) {
deleteConfirmBtn.addEventListener('click', function() {
if (deleteCallback) {
deleteCallback();
deleteCallback = null;
}
} catch (e) { alert("Error de conexión."); }
}
function clearDicomForm() {
document.getElementById('dicom-name').value = '';
document.getElementById('dicom-amount').value = '';
document.getElementById('dicom-notes').value = '';
document.getElementById('dicom-image-url').value = '';
document.getElementById('dicom-img-preview-container').classList.add('d-none');
}
// Image Compression & Upload
function compressImage(file, maxWidth, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = event => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(blob => resolve(blob), 'image/jpeg', quality);
};
};
bootstrap.Modal.getInstance(document.getElementById('dicomDeleteModal')).hide();
});
}
async function handleDicomUpload(input) {
const name = document.getElementById('dicom-name').value.trim();
if (!name) {
alert("Primero ingresa el nombre del cliente arriba para asociar la foto.");
input.value = '';
return;
}
const file = input.files[0];
if (!file) return;
try {
const compressedBlob = await compressImage(file, 800, 0.7);
const formData = new FormData();
formData.append('image', compressedBlob, `debt_${name}_${Date.now()}.jpg`);
formData.append('barcode', `debt_${name}`);
const res = await fetch('/upload_image', { method: 'POST', body: formData });
const data = await res.json();
if (res.ok) {
document.getElementById('dicom-image-url').value = data.image_url;
document.getElementById('dicom-img-preview').src = data.image_url;
document.getElementById('dicom-img-preview-container').classList.remove('d-none');
}
} catch (e) {
alert("Error procesando imagen.");
}
}
// Database Actions
async function submitDicom(action) {
const name = document.getElementById('dicom-name').value.trim();
const amount = document.getElementById('dicom-amount').value;
const notes = document.getElementById('dicom-notes').value;
const image_url = document.getElementById('dicom-image-url').value;
if (!name || amount <= 0 || isNaN(amount)) {
alert('Ingresa un nombre y monto válido mayor a 0.');
return;
}
try {
const res = await fetch('/api/dicom/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, amount, notes, action, image_url })
});
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('dicomModal')).hide();
window.location.reload();
} else {
alert("Error al guardar en la base de datos.");
}
} catch (e) { alert("Error de conexión."); }
}
let debtIdToDelete = null;
function forgiveDebt(id, name) {
debtIdToDelete = id;
document.getElementById('deleteDebtName').innerText = name;
bootstrap.Modal.getOrCreateInstance(document.getElementById('deleteDebtModal')).show();
}
async function executeForgiveDebt() {
if (!debtIdToDelete) return;
function showDeleteConfirm(title, desc, callback) {
const titleEl = document.getElementById('dicom-delete-title');
const descEl = document.getElementById('dicom-delete-desc');
const modalEl = document.getElementById('dicomDeleteModal');
try {
const res = await fetch(`/api/dicom/${debtIdToDelete}`, { method: 'DELETE' });
if (res.ok) {
window.location.reload();
} else {
alert("Error al eliminar el registro.");
}
} catch (e) {
alert("Error de conexión.");
} finally {
bootstrap.Modal.getInstance(document.getElementById('deleteDebtModal')).hide();
if (titleEl) titleEl.textContent = title;
if (descEl) descEl.textContent = desc;
deleteCallback = callback;
if (modalEl) {
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
}
// Global listener to accept with the Enter key
document.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
const deleteModal = document.getElementById('deleteDebtModal');
if (deleteModal && deleteModal.classList.contains('show')) {
event.preventDefault();
executeForgiveDebt();
// Format price cells on page load
document.querySelectorAll('.price-cell').forEach(el => {
const val = parseFloat(el.dataset.value) || 0;
el.innerText = clp.format(val);
});
function toggleDebtor(debtorId) {
const container = document.getElementById(`debtor-${debtorId}`);
const chevron = document.getElementById(`chevron-${debtorId}`);
if (container.classList.contains('d-none')) {
container.classList.remove('d-none');
chevron.classList.add('rotated');
expandedDebtorId = debtorId;
loadTickets(debtorId);
} else {
container.classList.add('d-none');
chevron.classList.remove('rotated');
expandedDebtorId = null;
}
}
async function loadTickets(debtorId) {
const container = document.getElementById(`tickets-container-${debtorId}`);
try {
const res = await fetch(`/api/dicom/debtor/${debtorId}`);
const tickets = await res.json();
if (tickets.length === 0) {
container.innerHTML = '<div class="text-muted small py-3">No hay tickets registrados</div>';
return;
}
container.innerHTML = tickets.map(ticket => `
<div class="ticket-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2">
<span class="ticket-status ${ticket.status}">
${ticket.status === 'paid' ? 'Pagado' : ticket.status === 'partial' ? 'Parcial' : 'Pendiente'}
</span>
<span class="text-muted small">${new Date(ticket.date).toLocaleDateString('es-CL', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
<div class="d-flex align-items-center gap-2">
<div class="text-end me-2">
<div class="fw-bold" style="font-size: 1.1rem;">${clp.format(ticket.total)}</div>
<small class="text-muted">Pagado: ${clp.format(ticket.amount_paid)}</small>
</div>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTicket(${ticket.id})" title="Eliminar ticket" style="border-radius: 8px; width: 34px; height: 34px; padding: 0; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<!-- Items -->
<div style="background: var(--input-bg); border-radius: 8px; padding: 12px 16px;">
${ticket.items.map(item => `
<div class="ticket-item-row d-flex justify-content-between align-items-center">
<div>
<span class="fw-semibold">${item.qty}x</span>
<span class="ms-2">${item.name}</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted">${clp.format(item.subtotal)}</span>
<button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deleteItem(${item.id})" title="Eliminar" style="border-radius: 6px; width: 24px; height: 24px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 0.75rem;">
<i class="bi bi-x"></i>
</button>
</div>
</div>
`).join('')}
</div>
${ticket.remaining > 0 ? `
<div class="d-flex justify-content-between align-items-center mt-3 pt-3" style="border-top: 1px solid rgba(255,255,255,0.05);">
<div>
<span class="text-muted small d-block">Restante</span>
<span class="text-danger fw-bold" style="font-size: 1.1rem;">${clp.format(ticket.remaining)}</span>
</div>
<button class="btn btn-sm btn-success btn-pay-all" onclick="openPaymentModal(${ticket.id}, ${ticket.remaining})">
<i class="bi bi-check-lg me-1"></i>Pagar Todo
</button>
</div>
` : '<div class="text-center mt-3"><span class="badge bg-success px-3 py-2"><i class="bi bi-check-circle me-1"></i>Pagado completamente</span></div>'}
</div>
`).join('');
} catch (e) {
console.error(e);
container.innerHTML = '<div class="text-danger small py-3">Error al cargar tickets</div>';
}
}
function openPaymentModal(ticketId, remaining) {
currentTicketId = ticketId;
payAllMode = false;
pendingPaymentAmount = remaining;
document.getElementById('payment-remaining-display').innerText = clp.format(remaining);
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
}
function openPayAllModal(debtorId, totalDebt) {
currentDebtorId = debtorId;
payAllMode = true;
pendingPaymentAmount = totalDebt;
document.getElementById('payment-remaining-display').innerText = clp.format(totalDebt);
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
}
// Focus monto recibido when vuelto modal opens
document.getElementById('dicomVueltoModal').addEventListener('shown.bs.modal', function() {
const input = document.getElementById('dicom-monto-recibido');
input.value = '';
setTimeout(() => input.focus(), 100);
});
// Enter key to confirm payment in vuelto modal
document.getElementById('dicom-monto-recibido').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !document.getElementById('btn-confirm-dicom-vuelto').disabled) {
confirmDicomPayment();
}
});
// Search
function filterDicom() {
const q = document.getElementById('dicom-search').value.toLowerCase();
document.querySelectorAll('#dicom-table tbody tr').forEach(row => {
const name = row.cells[1].innerText.toLowerCase(); // Index 1 is the name column
row.style.display = name.includes(q) ? '' : 'none';
// Keyboard shortcuts for payment modal
document.addEventListener('keydown', function(e) {
const modal = document.getElementById('paymentModal');
if (modal.classList.contains('show')) {
if (e.key === '1') confirmPayment('efectivo');
if (e.key === '2') confirmPayment('tarjeta');
if (e.key === '3') confirmPayment('transferencia');
}
});
// Store remaining value for calculation
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'data-value') {
const el = mutation.target;
el.innerText = clp.format(parseFloat(el.dataset.value));
}
});
});
document.querySelectorAll('.price-cell').forEach(el => {
observer.observe(el, { attributes: true });
});
// Delete debtor
function deleteDebtor(debtorId, debtorName) {
showDeleteConfirm(
'Eliminar deudor',
`¿Eliminar al deudor "${debtorName}" y todos sus tickets? Esta acción no se puede deshacer.`,
async function() {
try {
const res = await fetch(`/api/dicom/debtor/${debtorId}`, { method: 'DELETE' });
if (res.ok) {
location.reload();
} else {
const data = await res.json();
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
);
}
// Delete ticket
function deleteTicket(ticketId) {
showDeleteConfirm(
'Eliminar ticket',
'¿Eliminar este ticket y todos sus productos? Esta acción no se puede deshacer.',
async function() {
try {
const res = await fetch(`/api/dicom/ticket/${ticketId}`, { method: 'DELETE' });
if (res.ok) {
if (expandedDebtorId) {
loadTickets(expandedDebtorId);
}
} else {
const data = await res.json();
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
);
}
// Delete individual item
function deleteItem(itemId) {
showDeleteConfirm(
'Eliminar producto',
'¿Eliminar este producto del ticket? Esta acción no se puede deshacer.',
async function() {
try {
const res = await fetch(`/api/dicom/item/${itemId}`, { method: 'DELETE' });
const data = await res.json();
if (res.ok) {
if (expandedDebtorId) {
loadTickets(expandedDebtorId);
}
} else {
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
);
}
// Pay all tickets for a debtor
function payAllDebtor(debtorId, totalDebt) {
openPayAllModal(debtorId, totalDebt);
}
async function confirmPayment(paymentMethod) {
const amount = pendingPaymentAmount;
// For efectivo, open the vuelto modal
if (paymentMethod === 'efectivo') {
pendingPaymentMethod = paymentMethod;
document.getElementById('dicom-vuelto-total').innerText = clp.format(amount);
document.getElementById('dicom-monto-recibido').value = '';
document.getElementById('dicom-vuelto-amount').innerText = '$0';
document.getElementById('btn-confirm-dicom-vuelto').disabled = true;
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomVueltoModal')).show();
return;
}
// For other methods, process directly
await processDicomPayment(amount, paymentMethod);
}
function setDicomVuelto(amount) {
const formatted = amount.toLocaleString('es-CL');
document.getElementById('dicom-monto-recibido').value = formatted;
calculateDicomVuelto();
}
function calculateDicomVuelto() {
const receivedStr = document.getElementById('dicom-monto-recibido').value;
const received = parseInt(receivedStr.replace(/\./g, '')) || 0;
const total = pendingPaymentAmount;
const change = Math.max(0, received - total);
document.getElementById('dicom-vuelto-amount').innerText = clp.format(change);
document.getElementById('btn-confirm-dicom-vuelto').disabled = received < total;
}
async function confirmDicomPayment() {
await processDicomPayment(pendingPaymentAmount, pendingPaymentMethod);
bootstrap.Modal.getInstance(document.getElementById('dicomVueltoModal')).hide();
}
async function processDicomPayment(amount, paymentMethod) {
try {
let res;
if (payAllMode) {
res = await fetch(`/api/dicom/debtor/${currentDebtorId}/pay-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: amount, payment_method: paymentMethod })
});
} else {
res = await fetch(`/api/dicom/pay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket_id: currentTicketId, amount: amount, payment_method: paymentMethod })
});
}
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
payAllMode = false;
// Show success modal
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomSuccessModal')).show();
// Reload page after modal hides
setTimeout(() => location.reload(), 2500);
} else {
const data = await res.json();
alert('Error: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
alert('Error de conexión');
}
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -1,38 +1,24 @@
<!DOCTYPE html>
<html lang="es" data-theme="light">
{% extends "macros/base.html" %}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SekiPOS Login</title>
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/x-icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
{% block title %}Login{% endblock %}
<body>
<div class="login-box text-center">
<h2 class="fw-bold mb-1">SekiPOS</h2>
<p class="mb-4" style="opacity:.7;">¡Hola de nuevo!</p>
{% block content %}
<div class="login-box text-center">
<h2 class="fw-bold mb-1">SekiPOS</h2>
<p class="mb-4 text-muted">¡Hola de nuevo!</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="error-alert p-2 mb-3">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="error-alert p-2 mb-3">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<form method="POST">
<input class="form-control mb-3" type="text" name="username" placeholder="Usuario" required autofocus>
<input class="form-control mb-3" type="password" name="password" placeholder="Contraseña" required>
<button type="submit" class="btn btn-login w-100">
Iniciar Sesión
</button>
</form>
</div>
<script src="{{ url_for('static', filename='cookieStuff.js') }}"></script>
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
</body>
</html>
<form method="POST">
<input class="form-control mb-3" type="text" name="username" placeholder="Usuario" required autofocus>
<input class="form-control mb-3" type="password" name="password" placeholder="Contraseña" required>
<button type="submit" class="btn btn-login w-100">
Iniciar Sesión
</button>
</form>
</div>
{% endblock %}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="es" data-theme="light">
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,6 +7,20 @@
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/x-icon">
<script>
// Apply theme BEFORE any CSS loads to prevent flash
(function() {
var theme = localStorage.getItem('theme');
var isDark = (theme === 'dark') || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
if (localStorage.getItem('seki_food_mode') === 'true') {
document.body.classList.add('food-mode-active');
}
})();
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
@@ -17,13 +31,6 @@
</head>
<body>
<script>
// Inject Food Mode class immediately to appease the kitchen staff
if (localStorage.getItem('seki_food_mode') === 'true') {
document.body.classList.add('food-mode-active');
}
</script>
{% include 'macros/navbar.html' %}
<main class="container-fluid px-3">

View File

@@ -307,6 +307,23 @@
<i class="bi bi-egg-fried"></i> Modo Comida (Letras Grandes)
</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="setting-last-scanned">
<label class="form-check-label text-muted small" for="setting-last-scanned">
<i class="bi bi-upc-scan"></i> Mostrar "Último Escaneado" en Caja
</label>
</div>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Tamaño de letra Comanda</label>
<select id="setting-comanda-size" class="form-select">
<option value="small">Pequeño</option>
<option value="medium" selected>Mediano</option>
<option value="large">Grande</option>
<option value="xlarge">Extra Grande</option>
</select>
</div>
</div>
<div class="modal-footer d-flex">
<button class="btn btn-secondary flex-grow-1" data-bs-dismiss="modal">Cancelar</button>
@@ -329,6 +346,9 @@
document.getElementById('setting-auto-print').checked = localStorage.getItem('seki_auto_print') !== 'false';
document.getElementById('setting-ask-order-details').checked = localStorage.getItem('seki_ask_order_details') === 'true';
document.getElementById('setting-food-mode').checked = localStorage.getItem('seki_food_mode') === 'true';
document.getElementById('setting-last-scanned').checked = localStorage.getItem('seki_last_scanned') !== 'false';
const comandaSize = localStorage.getItem('seki_comanda_size') || 'medium';
document.getElementById('setting-comanda-size').value = comandaSize;
const showSii = localStorage.getItem('seki_show_sii') === 'true';
document.getElementById('setting-show-sii').checked = showSii;
@@ -348,11 +368,16 @@
const askDetails = document.getElementById('setting-ask-order-details').checked;
const showSii = document.getElementById('setting-show-sii').checked;
const foodMode = document.getElementById('setting-food-mode').checked;
const lastScanned = document.getElementById('setting-last-scanned').checked;
const comandaSize = document.getElementById('setting-comanda-size').value;
localStorage.setItem('seki_biz_name', bizName);
localStorage.setItem('seki_auto_print', autoPrint);
localStorage.setItem('seki_ask_order_details', askDetails);
localStorage.setItem('seki_show_sii', showSii);
localStorage.setItem('seki_food_mode', foodMode);
localStorage.setItem('modo_comida', foodMode);
localStorage.setItem('seki_last_scanned', lastScanned);
localStorage.setItem('seki_comanda_size', comandaSize);
if (showSii) {
localStorage.setItem('seki_rut', document.getElementById('setting-rut').value.trim());

View File

@@ -1,9 +1,10 @@
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
<span class="navbar-brand">
SekiPOS
<small class="text-muted fw-normal" style="font-size:0.65rem;">v2.2</small>
<small class="text-muted fw-normal" style="font-size:0.65rem;">v3.0</small>
</span>
{% if user and user.is_authenticated %}
<div class="ms-3 gap-2 d-flex">
<a href="/inventory" class="btn btn-sm {{ 'btn-primary' if active_page == 'inventory' else 'btn-outline-primary' }}">
<i class="bi bi-box-seam me-1"></i>Inventario
@@ -22,8 +23,10 @@
<i class="bi bi-journal-x me-1"></i>Dicom
</a>
</div>
{% endif %}
<div class="ms-auto">
{% if user and user.is_authenticated %}
<div class="dropdown">
<button class="btn btn-accent dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i>
@@ -32,7 +35,7 @@
<ul class="dropdown-menu dropdown-menu-end shadow">
<li>
<button class="dropdown-item" onclick="toggleTheme()">
<i class="bi bi-moon-stars me-2"></i>Modo Oscuro
<i class="bi bi-moon-stars me-2" id="theme-icon"></i><span id="theme-label">Modo Oscuro</span>
</button>
</li>
<li>
@@ -58,5 +61,10 @@
</li>
</ul>
</div>
{% else %}
<a href="/login" class="btn btn-accent">
<i class="bi bi-box-arrow-in-right me-1"></i>Iniciar Sesión
</a>
{% endif %}
</div>
</nav>