Compare commits
22 Commits
7f4b23efda
...
V3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5704980dbd | |||
| 10ba8d6f94 | |||
| ed6eac8bac | |||
| 4fd6feeea4 | |||
| 4fd2e9fe43 | |||
| 9675a0f9c2 | |||
| 780136915f | |||
| f6cd20f4fc | |||
| 24d408943d | |||
| 7723255a90 | |||
| 7a2b34ac0b | |||
| 544444accf | |||
| a5babd8131 | |||
| c2373c3ed6 | |||
| 33bc739e12 | |||
| caf73ce156 | |||
| 83f9f606de | |||
| 4b3ef3eb8b | |||
| 656d1bb895 | |||
| c0a737915e | |||
| b9bcd49a0c | |||
| 47cc480cf5 |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"python-envs.defaultEnvManager": "ms-python.python:pyenv"
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system"
|
||||
}
|
||||
19
Dockerfile
19
Dockerfile
@@ -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"]
|
||||
42
README.md
42
README.md
@@ -1,4 +1,4 @@
|
||||
# SekiPOS v2.1 🍫🥤
|
||||
# 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.
|
||||
|
||||
@@ -10,6 +10,44 @@ A reactive POS inventory system for software engineers with a snack addiction. F
|
||||
- **Secure:** Hashed password authentication via Flask-Login.
|
||||
- **On device scanner:** Add and scan products from within your phone!
|
||||
|
||||
## 📦 Building the Desktop App (.exe)
|
||||
|
||||
If you want to run SekiPOS as a standalone Windows application with its own embedded browser window, you need to compile it using PyInstaller.
|
||||
|
||||
### 1. Prerequisites
|
||||
You **must** use a stable Python release like **Python 3.11** or **3.12**. Pre-release versions (like 3.14) will fail to compile the PyWebView C# dependencies.
|
||||
|
||||
Install the required build tools globally for your stable Python version:
|
||||
```powershell
|
||||
py -3.11 -m pip install -r requirements.txt pyinstaller
|
||||
```
|
||||
|
||||
### 2. Prepare `app.py`
|
||||
Before compiling, scroll to the absolute bottom of `app.py` and ensure the standalone runner is active. It should look like this:
|
||||
```python
|
||||
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)
|
||||
```
|
||||
|
||||
### 3. Compile
|
||||
Run this exact command in your terminal. It includes the hidden SocketIO threads and bundles your web templates:
|
||||
```powershell
|
||||
py -3.11 -m PyInstaller --noconfirm --onedir --windowed --add-data "templates;templates/" --add-data "static;static/" --hidden-import "engineio.async_drivers.threading" --icon "static/favicon.png" app.py
|
||||
```
|
||||
|
||||
|
||||
### 4. Post-Build
|
||||
Your portable app will be generated inside the `dist\app` folder.
|
||||
* To keep your existing inventory, copy your `db/pos_database.db` and `static/cache/` folders from your source code into the new `dist\app` folder before running the `.exe`.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment (Server)
|
||||
|
||||
Build and run the central inventory server:
|
||||
@@ -35,6 +73,8 @@ services:
|
||||
sekipos:
|
||||
ports:
|
||||
- 5000:5000
|
||||
environment:
|
||||
- TZ=America/Santiago
|
||||
volumes:
|
||||
- YOUR_PATH/sekipos/db:/app/db
|
||||
- YOUR_PATH/sekipos/static/cache:/app/static/cache
|
||||
|
||||
685
app.py
685
app.py
@@ -1,640 +1,79 @@
|
||||
import os
|
||||
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 sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import zipfile
|
||||
import io
|
||||
import threading
|
||||
|
||||
# from dotenv import load_dotenv
|
||||
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
|
||||
|
||||
# load_dotenv()
|
||||
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
|
||||
|
||||
# MP_ACCESS_TOKEN = os.getenv('MP_ACCESS_TOKEN')
|
||||
# MP_TERMINAL_ID = os.getenv('MP_TERMINAL_ID')
|
||||
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
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
|
||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||
# --- PYINSTALLER WINDOWED MODE FIX ---
|
||||
if getattr(sys, 'frozen', False) and sys.platform == "win32":
|
||||
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
|
||||
|
||||
# Auth Setup
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = 'login'
|
||||
# --- 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'
|
||||
|
||||
DB_FILE = 'db/pos_database.db'
|
||||
CACHE_DIR = 'static/cache'
|
||||
# --- 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')''')
|
||||
init_login_manager(app)
|
||||
socketio.init_app(app, cors_allowed_origins="*", async_mode='threading')
|
||||
|
||||
# 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()
|
||||
|
||||
@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')
|
||||
with sqlite3.connect(DB_FILE) as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Determine the target date for the "Daily" stat
|
||||
target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0]
|
||||
|
||||
stats = {
|
||||
"daily": cur.execute("SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?", (target_date,)).fetchone()[0] or 0,
|
||||
"week": cur.execute("SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')").fetchone()[0] or 0,
|
||||
"month": cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')").fetchone()[0] or 0
|
||||
}
|
||||
|
||||
if selected_date:
|
||||
sales_data = cur.execute('''SELECT id, date, total, payment_method FROM sales
|
||||
WHERE date(date, 'localtime') = ?
|
||||
ORDER BY date DESC''', (selected_date,)).fetchall()
|
||||
else:
|
||||
sales_data = cur.execute('SELECT id, date, total, payment_method FROM sales ORDER BY date DESC LIMIT 100').fetchall()
|
||||
|
||||
return render_template('sales.html', active_page='sales', user=current_user, sales=sales_data, stats=stats, selected_date=selected_date)
|
||||
|
||||
|
||||
@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"
|
||||
)
|
||||
|
||||
# @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():
|
||||
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()
|
||||
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)
|
||||
webview.start(private_mode=False)
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
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, allow_unsafe_werkzeug=True)
|
||||
45
app.spec
Normal file
45
app.spec
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['app.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('templates', 'templates/'), ('static', 'static/')],
|
||||
hiddenimports=['engineio.async_drivers.threading'],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='app',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=['static\\favicon.png'],
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='app',
|
||||
)
|
||||
1
blueprints/__init__.py
Normal file
1
blueprints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# blueprints/__init__.py
|
||||
2
blueprints/__pycache__/.gitignore
vendored
Normal file
2
blueprints/__pycache__/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
60
blueprints/auth.py
Normal file
60
blueprints/auth.py
Normal 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
398
blueprints/finance.py
Normal 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
|
||||
138
blueprints/inventory.py
Normal file
138
blueprints/inventory.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import os
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
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('/upload_image', methods=['POST'])
|
||||
@login_required
|
||||
def upload_image():
|
||||
if 'image' not in request.files:
|
||||
return jsonify({"error": "No image file provided"}), 400
|
||||
|
||||
file = request.files['image']
|
||||
barcode = request.form.get('barcode', '')
|
||||
|
||||
if not barcode:
|
||||
return jsonify({"error": "No barcode provided"}), 400
|
||||
|
||||
if file.filename == '':
|
||||
return jsonify({"error": "Empty file"}), 400
|
||||
|
||||
cache_dir = current_app.config['CACHE_DIR']
|
||||
ext = '.jpg'
|
||||
local_filename = f"{secure_filename(barcode)}{ext}"
|
||||
local_path = os.path.join(cache_dir, local_filename)
|
||||
|
||||
file.save(local_path)
|
||||
|
||||
image_url = f"/static/cache/{local_filename}"
|
||||
return jsonify({"image_url": image_url}), 200
|
||||
|
||||
@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
115
blueprints/pos.py
Normal 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
147
blueprints/sales.py
Normal 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
build/.gitignore
vendored
Normal file
2
build/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
core/__pycache__/.gitignore
vendored
Normal file
2
core/__pycache__/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
79
core/db.py
Normal file
79
core/db.py
Normal 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)
|
||||
3
core/events.py
Normal file
3
core/events.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
socketio = SocketIO()
|
||||
23
core/openfood.py
Normal file
23
core/openfood.py
Normal 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
46
core/utils.py
Normal 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
|
||||
2
dist/.gitignore
vendored
Normal file
2
dist/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -4,3 +4,4 @@ Flask-SocketIO==5.6.1
|
||||
requests==2.32.5
|
||||
eventlet==0.36.1
|
||||
python-dotenv==1.2.2
|
||||
pywebview==6.2.1
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
<div id="debtors-list">
|
||||
{% 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 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>
|
||||
{% 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>
|
||||
<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>
|
||||
<button class="btn btn-sm btn-outline-danger ms-1"
|
||||
onclick="forgiveDebt({{ d[0] }}, '{{ d[1] }}')" title="Eliminar Registro">
|
||||
{% 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>
|
||||
</td>
|
||||
</tr>
|
||||
<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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
{% endcall %}
|
||||
<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>
|
||||
</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');
|
||||
const deleteConfirmBtn = document.getElementById('dicom-delete-confirm-btn');
|
||||
if (deleteConfirmBtn) {
|
||||
deleteConfirmBtn.addEventListener('click', function() {
|
||||
if (deleteCallback) {
|
||||
deleteCallback();
|
||||
deleteCallback = null;
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('dicomDeleteModal')).hide();
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteConfirm(title, desc, callback) {
|
||||
const titleEl = document.getElementById('dicom-delete-title');
|
||||
const descEl = document.getElementById('dicom-delete-desc');
|
||||
const modalEl = document.getElementById('dicomDeleteModal');
|
||||
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
if (descEl) descEl.textContent = desc;
|
||||
deleteCallback = callback;
|
||||
if (modalEl) {
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||
}
|
||||
}
|
||||
|
||||
// Format price cells on page load
|
||||
document.querySelectorAll('.price-cell').forEach(el => {
|
||||
const val = parseFloat(el.dataset.value) || 0;
|
||||
el.innerText = clp.format(val);
|
||||
});
|
||||
|
||||
// Modal Control Functions
|
||||
function openNewModal() {
|
||||
clearDicomForm();
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomModal')).show();
|
||||
setTimeout(() => document.getElementById('dicom-name').focus(), 500);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let currentQuickEditName = '';
|
||||
async function loadTickets(debtorId) {
|
||||
const container = document.getElementById(`tickets-container-${debtorId}`);
|
||||
|
||||
function openQuickEditModal(name, notes) {
|
||||
currentQuickEditName = name;
|
||||
try {
|
||||
const res = await fetch(`/api/dicom/debtor/${debtorId}`);
|
||||
const tickets = await res.json();
|
||||
|
||||
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.');
|
||||
if (tickets.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted small py-3">No hay tickets registrados</div>';
|
||||
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: ''
|
||||
})
|
||||
});
|
||||
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>
|
||||
|
||||
if (res.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickEditModal')).hide();
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Error al actualizar la deuda.");
|
||||
<!-- 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>';
|
||||
}
|
||||
} 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');
|
||||
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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
};
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
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.");
|
||||
// Focus monto recibido when vuelto modal opens
|
||||
document.getElementById('dicomVueltoModal').addEventListener('shown.bs.modal', function() {
|
||||
const input = document.getElementById('dicom-monto-recibido');
|
||||
input.value = '';
|
||||
return;
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
// 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 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();
|
||||
|
||||
const res = await fetch(`/api/dicom/debtor/${debtorId}`, { method: 'DELETE' });
|
||||
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');
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert('Error: ' + (data.error || 'Error desconocido'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Error procesando imagen.");
|
||||
alert('Error de conexión');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!name || amount <= 0 || isNaN(amount)) {
|
||||
alert('Ingresa un nombre y monto válido mayor a 0.');
|
||||
// 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 {
|
||||
const res = await fetch('/api/dicom/update', {
|
||||
let res;
|
||||
if (payAllMode) {
|
||||
res = await fetch(`/api/dicom/debtor/${currentDebtorId}/pay-all`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, amount, notes, action, image_url })
|
||||
body: JSON.stringify({ amount: amount, payment_method: paymentMethod })
|
||||
});
|
||||
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."); }
|
||||
res = await fetch(`/api/dicom/pay`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ticket_id: currentTicketId, amount: amount, payment_method: paymentMethod })
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/dicom/${debtIdToDelete}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
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 {
|
||||
alert("Error al eliminar el registro.");
|
||||
const data = await res.json();
|
||||
alert('Error: ' + (data.error || 'Error desconocido'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Error de conexión.");
|
||||
} finally {
|
||||
bootstrap.Modal.getInstance(document.getElementById('deleteDebtModal')).hide();
|
||||
alert('Error de conexión');
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
228
templates/gastos.html
Normal file
228
templates/gastos.html
Normal file
@@ -0,0 +1,228 @@
|
||||
{% extends "macros/base.html" %}
|
||||
|
||||
{% block title %}Gastos y Utilidad{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h3 class="mb-0"><i class="bi bi-wallet2 me-2"></i>Gastos y Utilidad</h3>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<select id="month-select" class="form-select form-select-sm"
|
||||
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
|
||||
onchange="applyDateFilter()">
|
||||
</select>
|
||||
<select id="year-select" class="form-select form-select-sm"
|
||||
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
|
||||
onchange="applyDateFilter()">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="discord-card p-3 shadow-sm text-center border-success" style="border-bottom: 4px solid #198754;">
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Ventas Totales</h6>
|
||||
<h2 class="price-cell mb-0 text-success" style="font-weight: 800;" data-value="{{ sales_total }}"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="discord-card p-3 shadow-sm text-center border-danger" style="border-bottom: 4px solid #dc3545;">
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Gastos Totales</h6>
|
||||
<h2 class="price-cell mb-0 text-danger" style="font-weight: 800;" data-value="{{ expenses_total }}"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="discord-card p-3 shadow-sm text-center" style="border-bottom: 4px solid {% if net_profit >= 0 %}#0dcaf0{% else %}#dc3545{% endif %};">
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Utilidad Neta</h6>
|
||||
<h2 class="price-cell mb-0" style="color: {% if net_profit >= 0 %}#0dcaf0{% else %}#dc3545{% endif %}; font-weight: 800;" data-value="{{ net_profit }}"></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="discord-card p-3 shadow-sm">
|
||||
<h5 class="mb-3"><i class="bi bi-plus-circle me-2"></i>Registrar Gasto</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Descripción</label>
|
||||
<input type="text" id="gasto-desc" class="form-control" placeholder="Ej: Pago de luz, Mercadería..." autocomplete="off">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Monto (CLP)</label>
|
||||
<input type="text" inputmode="numeric" id="gasto-monto" class="form-control fs-5 fw-bold"
|
||||
placeholder="$0"
|
||||
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : '';"
|
||||
onkeydown="if(event.key === 'Enter') submitGasto()">
|
||||
</div>
|
||||
<button class="btn btn-warning w-100 fw-bold" onclick="submitGasto()">
|
||||
<i class="bi bi-save me-1"></i> Guardar Gasto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="discord-card p-3 shadow-sm">
|
||||
<h5 class="mb-3"><i class="bi bi-list-ul me-2"></i>Historial de Gastos</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Descripción</th>
|
||||
<th class="text-end">Monto</th>
|
||||
<th style="width: 1%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if expenses %}
|
||||
{% for e in expenses %}
|
||||
<tr>
|
||||
<td class="utc-date text-muted">{{ e[1] }}</td>
|
||||
<td>{{ e[2] }}</td>
|
||||
<td class="text-end text-danger fw-bold price-cell" data-value="{{ e[3] }}"></td>
|
||||
<td class="text-nowrap">
|
||||
<button class="btn btn-sm btn-outline-danger py-0 px-2" onclick="deleteGasto({{ e[0] }})" title="Eliminar">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">No hay gastos registrados en este mes.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="deleteGastoModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-danger">
|
||||
<div class="modal-header border-0 pb-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">¿Eliminar Gasto?</h4>
|
||||
<p class="text-muted px-3">Esta acción eliminará el registro permanentemente y recalculará la utilidad neta.</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" onclick="executeDeleteGasto()">Sí, 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 gastoToDelete = null;
|
||||
|
||||
// Format UI numbers
|
||||
document.querySelectorAll('.price-cell').forEach(td => {
|
||||
td.innerText = clp.format(td.getAttribute('data-value'));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.utc-date').forEach(el => {
|
||||
const date = new Date(el.innerText + " UTC");
|
||||
if (!isNaN(date)) {
|
||||
el.innerText = date.toLocaleString('es-CL', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Build the Split Dropdowns ---
|
||||
const currentSelected = "{{ selected_month }}"; // Comes from backend as "YYYY-MM"
|
||||
const [selYear, selMonth] = currentSelected.split('-');
|
||||
|
||||
const monthSelect = document.getElementById('month-select');
|
||||
const yearSelect = document.getElementById('year-select');
|
||||
|
||||
const monthNames = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"];
|
||||
|
||||
// Populate Months
|
||||
monthNames.forEach((name, index) => {
|
||||
const val = String(index + 1).padStart(2, '0');
|
||||
const option = document.createElement('option');
|
||||
option.value = val;
|
||||
option.innerText = name;
|
||||
if (val === selMonth) option.selected = true;
|
||||
monthSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Populate Years (Current year +/- a few years)
|
||||
const currentYear = new Date().getFullYear();
|
||||
for (let y = currentYear - 3; y <= currentYear + 1; y++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = y;
|
||||
option.innerText = y;
|
||||
if (y.toString() === selYear) option.selected = true;
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
|
||||
// Trigger URL change when either dropdown is touched
|
||||
function applyDateFilter() {
|
||||
const m = monthSelect.value;
|
||||
const y = yearSelect.value;
|
||||
window.location.href = `/gastos?month=${y}-${m}`;
|
||||
}
|
||||
|
||||
async function submitGasto() {
|
||||
const descInput = document.getElementById('gasto-desc');
|
||||
const montoInput = document.getElementById('gasto-monto');
|
||||
|
||||
const desc = descInput.value.trim();
|
||||
const rawMonto = montoInput.value.replace(/\./g, '');
|
||||
const amount = parseInt(rawMonto, 10);
|
||||
|
||||
if (!desc || isNaN(amount) || amount <= 0) {
|
||||
alert("Por favor ingresa una descripción y un monto válido.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/gastos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: desc, amount: amount })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
descInput.value = '';
|
||||
montoInput.value = '';
|
||||
window.location.href = window.location.pathname + window.location.search;
|
||||
} else {
|
||||
alert("Error al guardar el gasto.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error de conexión.");
|
||||
}
|
||||
}
|
||||
|
||||
// Open Modal
|
||||
function deleteGasto(id) {
|
||||
gastoToDelete = id;
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('deleteGastoModal')).show();
|
||||
}
|
||||
|
||||
// Execute the backend call
|
||||
async function executeDeleteGasto() {
|
||||
if (!gastoToDelete) return;
|
||||
try {
|
||||
const res = await fetch(`/api/gastos/${gastoToDelete}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
window.location.href = window.location.pathname + window.location.search;
|
||||
} else {
|
||||
alert("Error al eliminar.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error de conexión.");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -82,14 +82,20 @@
|
||||
<div class="discord-card p-3">
|
||||
<h6 id="form-title" class="mb-3 fw-bold">Editar / Crear</h6>
|
||||
<form action="/upsert" method="POST" id="product-form">
|
||||
<input class="form-control mb-2" type="text" name="barcode" id="form-barcode" placeholder="Barcode"
|
||||
<div class="input-group mb-2">
|
||||
<input class="form-control" type="text" name="barcode" id="form-barcode" placeholder="Barcode"
|
||||
required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="generateBarcode()" title="Generar código">
|
||||
<i class="bi bi-upc-scan"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input class="form-control mb-2" type="text" name="name" id="form-name" placeholder="Nombre" required>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-8">
|
||||
<input class="form-control" type="number" name="price" id="form-price"
|
||||
placeholder="Precio (CLP)" required>
|
||||
<input class="form-control" type="text" inputmode="numeric" name="price" id="form-price"
|
||||
placeholder="Precio (CLP)" required
|
||||
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : '';">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<select class="form-select" name="unit_type" id="form-unit-type">
|
||||
@@ -301,7 +307,6 @@ permanentemente', 'executeBulkDelete()') %}
|
||||
document.getElementById('form-name').value = n;
|
||||
|
||||
// Force integers here to nuke the decimals once and for all
|
||||
document.getElementById('form-price').value = p ? parseInt(p, 10) : '';
|
||||
document.getElementById('form-stock').value = stock ? parseInt(stock, 10) : 0;
|
||||
|
||||
document.getElementById('form-unit-type').value = unit || 'unit';
|
||||
@@ -318,7 +323,7 @@ permanentemente', 'executeBulkDelete()') %}
|
||||
document.getElementById('form-title').innerText = t;
|
||||
document.getElementById('display-img').src = displayImg;
|
||||
document.getElementById('display-name').innerText = n || 'Producto Nuevo';
|
||||
document.getElementById('display-price').innerText = clp.format(p || 0);
|
||||
document.getElementById('form-price').value = p ? parseInt(p, 10).toLocaleString('es-CL') : '';
|
||||
document.getElementById('display-barcode').innerText = b;
|
||||
|
||||
toggleStockInput(); // Show/hide stock input based on unit type
|
||||
@@ -380,6 +385,8 @@ permanentemente', 'executeBulkDelete()') %}
|
||||
|
||||
function clearForm() {
|
||||
document.getElementById('product-form').reset();
|
||||
document.getElementById('form-price').value = '';
|
||||
document.getElementById('form-price').dataset.raw = '';
|
||||
document.getElementById('form-title').innerText = 'Editar / Crear';
|
||||
// Reset preview card
|
||||
document.getElementById('display-img').src = './static/placeholder.png';
|
||||
@@ -738,6 +745,22 @@ permanentemente', 'executeBulkDelete()') %}
|
||||
}
|
||||
}
|
||||
|
||||
function generateBarcode() {
|
||||
const existing = new Set(
|
||||
Array.from(document.querySelectorAll('#inventoryTable tbody tr'))
|
||||
.map(tr => tr.getAttribute('data-barcode'))
|
||||
);
|
||||
const prefix = '78';
|
||||
let code;
|
||||
do {
|
||||
code = prefix;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
code += Math.floor(Math.random() * 10);
|
||||
}
|
||||
} while (existing.has(code));
|
||||
document.getElementById('form-barcode').value = code;
|
||||
}
|
||||
|
||||
function toggleStockInput() {
|
||||
const unitSelect = document.getElementById('form-unit-type');
|
||||
const stockInput = document.getElementById('form-stock');
|
||||
@@ -753,5 +776,12 @@ permanentemente', 'executeBulkDelete()') %}
|
||||
}
|
||||
|
||||
document.getElementById('form-unit-type').addEventListener('change', toggleStockInput);
|
||||
|
||||
document.getElementById('product-form').addEventListener('submit', function() {
|
||||
const priceInput = document.getElementById('form-price');
|
||||
priceInput.value = priceInput.value.replace(/\./g, '');
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,20 +1,22 @@
|
||||
<!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">
|
||||
{% block head %}
|
||||
<style>
|
||||
.login-box {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="tsparticles"></div>
|
||||
|
||||
<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>
|
||||
<p class="mb-4 text-muted">¡Hola de nuevo!</p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
@@ -29,10 +31,74 @@
|
||||
Iniciar Sesión
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='cookieStuff.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/tsparticles@3.3.0/tsparticles.bundle.min.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
await tsParticles.load({
|
||||
id: "tsparticles",
|
||||
options: {
|
||||
fullScreen: {
|
||||
enable: true,
|
||||
zIndex: -1
|
||||
},
|
||||
background: {
|
||||
color: {
|
||||
value: "transparent",
|
||||
},
|
||||
},
|
||||
fpsLimit: 60,
|
||||
interactivity: {
|
||||
events: {
|
||||
onHover: {
|
||||
enable: true,
|
||||
mode: "grab",
|
||||
},
|
||||
},
|
||||
modes: {
|
||||
grab: {
|
||||
distance: 150,
|
||||
links: {
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
particles: {
|
||||
color: {
|
||||
value: "#9b59b6",
|
||||
},
|
||||
links: {
|
||||
color: "#9b59b6",
|
||||
distance: 150,
|
||||
enable: true,
|
||||
opacity: 0.6,
|
||||
width: 1.5,
|
||||
},
|
||||
move: {
|
||||
enable: true,
|
||||
speed: 1,
|
||||
},
|
||||
number: {
|
||||
density: {
|
||||
enable: true,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
value: 80,
|
||||
},
|
||||
opacity: {
|
||||
value: 0.9,
|
||||
},
|
||||
size: {
|
||||
value: { min: 1, max: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
@@ -16,12 +30,17 @@
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% include 'macros/navbar.html' %}
|
||||
|
||||
<main class="container-fluid px-3">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% from 'macros/modals.html' import settings_modal %}
|
||||
{{ settings_modal() }}
|
||||
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='cookieStuff.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
|
||||
|
||||
@@ -49,80 +49,365 @@
|
||||
<div id="receipt-print-zone{{ id_suffix }}" class="d-none d-print-block">
|
||||
<style>
|
||||
@media print {
|
||||
/* Tell the browser this is a continuous 80mm thermal roll */
|
||||
@page {
|
||||
margin: 0;
|
||||
size: 80mm auto;
|
||||
@page { margin: 0; size: 80mm auto; }
|
||||
nav, .discord-card, .modal, .row { display: none !important; }
|
||||
body * { visibility: hidden; }
|
||||
#receipt-print-zone{{ id_suffix }}, #receipt-print-zone{{ id_suffix }} * { visibility: visible; }
|
||||
#receipt-print-zone{{ id_suffix }} {
|
||||
position: absolute; left: 0; top: 0; width: 80mm;
|
||||
padding: 2mm 5mm; margin: 0; display: block !important;
|
||||
font-family: 'Courier New', Courier, monospace; font-size: 11px; color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Nuke the rest of the layout from the document flow so it takes up 0 height */
|
||||
nav, .discord-card, .modal, .row {
|
||||
/* --- MODO COMIDA (KITCHEN MODE) PRINT STYLES --- */
|
||||
@media print {
|
||||
body.food-mode-active #receipt-print-zone{{ id_suffix }} {
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Make the Item Name massive */
|
||||
body.food-mode-active .receipt-table td:nth-child(2) {
|
||||
font-size: 26pt !important;
|
||||
font-weight: 900 !important;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Make the Quantity large */
|
||||
body.food-mode-active .receipt-table td:nth-child(1) {
|
||||
font-size: 22pt !important;
|
||||
font-weight: bold !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Hide the pricing (kitchen doesn't care) */
|
||||
body.food-mode-active .receipt-table th:nth-child(3),
|
||||
body.food-mode-active .receipt-table td:nth-child(3) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body * {
|
||||
visibility: hidden;
|
||||
/* Hide all the irrelevant legal/business clutter */
|
||||
body.food-mode-active .receipt-biz-name,
|
||||
body.food-mode-active .receipt-standard-info,
|
||||
body.food-mode-active .receipt-sii-info,
|
||||
body.food-mode-active .receipt-subtotal-row,
|
||||
body.food-mode-active .receipt-total-row,
|
||||
body.food-mode-active #receipt-payment-info{{ id_suffix }} {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Resurrect the receipt and put it in the top left corner */
|
||||
#receipt-print-zone{{ id_suffix }}, #receipt-print-zone{{ id_suffix }} * {
|
||||
visibility: visible;
|
||||
}
|
||||
#receipt-print-zone{{ id_suffix }} {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 80mm;
|
||||
padding: 2mm 5mm;
|
||||
margin: 0;
|
||||
display: block !important;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 10px;
|
||||
color: #000;
|
||||
/* Make Order notes extra visible */
|
||||
body.food-mode-active #receipt-order-info{{ id_suffix }} {
|
||||
font-size: 16pt !important;
|
||||
border-top: 2px solid #000 !important;
|
||||
border-bottom: 2px solid #000 !important;
|
||||
padding: 10px 0 !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-table { width: 100%; border-collapse: collapse; font-family: monospace; font-size: 12px; }
|
||||
.receipt-header { text-align: center; margin-bottom: 10px; border-bottom: 1px dashed #000; padding-bottom: 5px; }
|
||||
.receipt-total-row { border-top: 1px dashed #000; margin-top: 5px; padding-top: 5px; font-weight: bold; }
|
||||
.receipt-table { width: 100%; border-collapse: collapse; font-family: 'Courier New', Courier, monospace; font-size: 11px; margin-top: 10px;}
|
||||
.receipt-header { text-align: center; margin-bottom: 5px; }
|
||||
.sii-box { border: 2px solid #000; padding: 5px; text-align: center; font-weight: bold; }
|
||||
.receipt-subtotal-row { display: flex; justify-content: space-between; font-size: 11px; }
|
||||
.receipt-total-row { margin-top: 2px; padding-top: 2px; font-weight: bold; font-size: 14px; display: flex; justify-content: space-between; }
|
||||
.ted-block { text-align: center; margin-top: 15px; margin-bottom: 10px; }
|
||||
</style>
|
||||
|
||||
<div class="receipt-header">
|
||||
<h3 style="margin: 0; font-weight: 800;">SekiPOS</h3>
|
||||
<div style="font-size: 10px; margin-bottom: 5px;" id="receipt-type{{ id_suffix }}">Comprobante de Venta</div>
|
||||
<div style="font-size: 11px; font-weight: bold;">
|
||||
Ticket Nº <span id="receipt-ticket-id{{ id_suffix }}"></span>
|
||||
<h3 style="margin: 0; font-weight: 800; font-size: 16px;" class="receipt-biz-name text-uppercase">SekiPOS</h3>
|
||||
|
||||
<div class="receipt-sii-info" style="display: none; font-size: 10px; margin-top: 2px;">
|
||||
<div class="receipt-giro text-uppercase"></div>
|
||||
<div class="receipt-address text-uppercase"></div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-title-box" style="text-align: center; font-weight: bold; margin: 10px 0;">
|
||||
<div class="receipt-sii-info" style="display: none;">
|
||||
<div style="font-size: 12px;">R.U.T.: <span class="receipt-rut"></span></div>
|
||||
<div style="font-size: 12px; margin-top: 2px;">BOLETA ELECTRONICA</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-standard-info" style="font-size: 12px;">
|
||||
COMPROBANTE DE VENTA
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2px; font-size: 12px;">
|
||||
Nº <span id="receipt-ticket-id{{ id_suffix }}"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-sii-info" style="display: none; font-weight: bold; font-size: 10px; margin-bottom: 10px;">
|
||||
S.I.I. - CONCEPCION
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const bizName = localStorage.getItem('seki_biz_name') || 'SekiPOS';
|
||||
const showSii = localStorage.getItem('seki_show_sii') === 'true';
|
||||
|
||||
document.querySelectorAll('.receipt-biz-name').forEach(el => el.innerText = bizName);
|
||||
|
||||
// Toggle visibility based on SII mode
|
||||
document.querySelectorAll('.receipt-sii-info').forEach(el => el.style.display = showSii ? 'block' : 'none');
|
||||
document.querySelectorAll('.receipt-standard-info').forEach(el => el.style.display = showSii ? 'none' : 'block');
|
||||
|
||||
// Toggle the heavy border box
|
||||
document.querySelectorAll('.receipt-title-box').forEach(el => {
|
||||
if (showSii) {
|
||||
el.classList.add('sii-box');
|
||||
el.style.margin = '10px 15%';
|
||||
} else {
|
||||
el.classList.remove('sii-box');
|
||||
el.style.margin = '10px 0';
|
||||
}
|
||||
});
|
||||
|
||||
if (showSii) {
|
||||
document.querySelectorAll('.receipt-rut').forEach(el => el.innerText = localStorage.getItem('seki_rut') || '-');
|
||||
document.querySelectorAll('.receipt-giro').forEach(el => el.innerText = localStorage.getItem('seki_giro') || '-');
|
||||
document.querySelectorAll('.receipt-address').forEach(el => el.innerText = localStorage.getItem('seki_address') || '-');
|
||||
|
||||
// Load Barcode generator for the SII PDF417 TED
|
||||
if (!document.getElementById('bwip-script')) {
|
||||
const script = document.createElement('script');
|
||||
script.id = 'bwip-script';
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/bwip-js/3.4.1/bwip-js-min.js';
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
const tedData = "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iSVNPLTg4NTktMSI/Pgo8VEVEIHZlcnNpb249IjEuMCI+PEREPjxSRT43NjEyMzQ1Ni03PC9SRT48VEQ+Mzk8L1REPjxGPjEyMzwvRj48RkU+MjAyNi0wNC0xNTwvRkU+PFJSPjExMTExMTExLTE8L1JSPjxSU1I+SlVBTiBQRVJFPC9SU1I+PE1OVD4xNTAwMDwvTU5UPjxJVDE+VmFyaW9zPC9JVDE+PENBRiB2ZXJzaW9uPSIxLjAiPjxEQT48UkU+NzYxMjM0NTYtNzwvUkU+PFJTPlNLSVBPUyBMSU1JVEFEQTwvUlM+PFREPjM5PC9URD48Uk5HPjxEPjE8L0Q+PEg+MTAwMDwvSD48L1JORz48RkE+MjAyNi0wNC0xNTwvRkE+PFJTQVBLPjxNPndYeFl5WnouLi48L00+PEU+QXc9PTwvRT48L1JTQVBLPjxJREs+MTAwPC9JREs+PC9EQT48RlJNQSBhbGdvcml0bW89IlNIQTF3aXRoUlNBIj5hQmNEZUY8L0ZSTUE+PC9DQUY+PFRTVEVEPjIwMjYtMDQtMTVUMTI6MDA6MDA8L1RTVEVEPjwvREQ+PEZSTVQgYWxnb3JpdG1vPSJTSEExd2l0aFJTQSI+elp5WXhYPC9GUk1UPjwvVEVEPg==";
|
||||
|
||||
const drawTED = () => {
|
||||
if (typeof bwipjs === 'undefined') {
|
||||
setTimeout(drawTED, 100);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
bwipjs.toCanvas('receipt-ted-canvas{{ id_suffix }}', {
|
||||
bcid: 'pdf417',
|
||||
text: tedData,
|
||||
scale: 2,
|
||||
columns: 12
|
||||
});
|
||||
} catch (e) { console.error("Error drawing TED:", e); }
|
||||
};
|
||||
drawTED();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="font-size: 11px; text-align: left; display: flex; justify-content: space-between;">
|
||||
<span>Fecha: <span id="receipt-date{{ id_suffix }}"></span></span>
|
||||
</div>
|
||||
|
||||
<div id="receipt-order-info{{ id_suffix }}" style="display: none; margin-top: 5px; padding-top: 5px; border-top: 1px dashed #000; text-align: left; font-size: 11px;">
|
||||
<div style="font-weight: bold;">Cliente: <span id="receipt-client-name{{ id_suffix }}"></span></div>
|
||||
<div id="receipt-pickup-container{{ id_suffix }}" style="display: none; font-weight: bold;">Retiro: <span id="receipt-pickup-time{{ id_suffix }}"></span></div>
|
||||
<div>Notas: <span id="receipt-order-notes{{ id_suffix }}"></span></div>
|
||||
</div>
|
||||
<div id="receipt-date{{ id_suffix }}" style="font-size: 11px;"></div>
|
||||
</div>
|
||||
|
||||
<table class="receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 15%; text-align: left;">Cant</th>
|
||||
<th style="width: 60%; padding-left: 5px; text-align: left;">Desc</th>
|
||||
<th style="width: 25%; text-align: right;">Total</th>
|
||||
<tr style="border-bottom: 1px dashed #000; border-top: 1px dashed #000;">
|
||||
<th style="width: 15%; text-align: left; padding: 2px 0;">Cant</th>
|
||||
<th style="width: 55%; padding-left: 5px; text-align: left;">Articulo</th>
|
||||
<th style="width: 30%; text-align: right;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="receipt-items-print{{ id_suffix }}"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="receipt-total-row d-flex justify-content-between">
|
||||
<div style="border-top: 1px dashed #000; margin-top: 5px; padding-top: 5px;">
|
||||
<div class="receipt-subtotal-row">
|
||||
<span>NETO:</span>
|
||||
<span id="receipt-neto-print{{ id_suffix }}"></span>
|
||||
</div>
|
||||
<div class="receipt-subtotal-row">
|
||||
<span>I.V.A. (19%):</span>
|
||||
<span id="receipt-iva-print{{ id_suffix }}"></span>
|
||||
</div>
|
||||
<div class="receipt-total-row">
|
||||
<span>TOTAL:</span>
|
||||
<span id="receipt-total-print{{ id_suffix }}"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="receipt-payment-info{{ id_suffix }}">
|
||||
<div id="receipt-payment-info{{ id_suffix }}" style="font-size: 11px; margin-top: 5px;">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>RECIBIDO:</span>
|
||||
<span>Efectivo:</span>
|
||||
<span id="receipt-paid-print{{ id_suffix }}"></span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>VUELTO:</span>
|
||||
<span>Vuelto:</span>
|
||||
<span id="receipt-change-print{{ id_suffix }}"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 20px; font-size: 10px;">¡Gracias por su compra!</div>
|
||||
<div class="receipt-sii-info ted-block" style="display: none;">
|
||||
<canvas id="receipt-ted-canvas{{ id_suffix }}" style="max-width: 90%; height: auto; image-rendering: pixelated; margin: 0 auto; display: block;"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="receipt-sii-info" style="display: none; text-align: center; font-size: 9px; font-weight: bold;">
|
||||
Timbre electrónico SII - Res. 80 de 2014<br>
|
||||
Verifique documento en sii.cl<br>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 15px; font-size: 11px; font-weight: bold;">
|
||||
¡Gracias por su compra!
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro settings_modal() %}
|
||||
<div class="modal fade" id="settingsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-gear-fill me-2"></i>Configuración del POS</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||
<div class="mb-4">
|
||||
<label class="form-label text-muted small mb-1">Nombre del Local</label>
|
||||
<input type="text" id="setting-biz-name" class="form-control" placeholder="Ej: Mi Tiendita" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="setting-show-sii" onchange="toggleSiiFields()">
|
||||
<label class="form-check-label text-muted small" for="setting-show-sii">
|
||||
Spoof datos legales para SII (Boleta)
|
||||
</label>
|
||||
</div>
|
||||
<div id="setting-sii-fields" class="d-none border-start border-2 border-primary ps-3 mb-4 mt-2">
|
||||
<input type="text" id="setting-rut" class="form-control form-control-sm mb-2" placeholder="RUT (Ej: 76.123.456-7)">
|
||||
<input type="text" id="setting-giro" class="form-control form-control-sm mb-2" placeholder="Giro (Ej: Minimarket)">
|
||||
<input type="text" id="setting-address" class="form-control form-control-sm" placeholder="Dirección y Comuna">
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="setting-auto-print">
|
||||
<label class="form-check-label text-muted small" for="setting-auto-print">
|
||||
Imprimir automáticamente al finalizar venta
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="setting-ask-order-details">
|
||||
<label class="form-check-label text-muted small" for="setting-ask-order-details">
|
||||
<i class="bi bi-egg-fried"></i> Solicitar Nombre/Notas al cobrar
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="setting-food-mode">
|
||||
<label class="form-check-label text-muted small" for="setting-food-mode">
|
||||
<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>
|
||||
<button class="btn btn-primary flex-grow-1" onclick="savePosSettings()">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleSiiFields() {
|
||||
const isChecked = document.getElementById('setting-show-sii').checked;
|
||||
document.getElementById('setting-sii-fields').classList.toggle('d-none', !isChecked);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modalEl = document.getElementById('settingsModal');
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('show.bs.modal', () => {
|
||||
document.getElementById('setting-biz-name').value = localStorage.getItem('seki_biz_name') || 'SekiPOS';
|
||||
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;
|
||||
document.getElementById('setting-rut').value = localStorage.getItem('seki_rut') || '';
|
||||
document.getElementById('setting-giro').value = localStorage.getItem('seki_giro') || '';
|
||||
document.getElementById('setting-address').value = localStorage.getItem('seki_address') || '';
|
||||
|
||||
toggleSiiFields();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function savePosSettings() {
|
||||
const bizName = document.getElementById('setting-biz-name').value.trim() || 'SekiPOS';
|
||||
const autoPrint = document.getElementById('setting-auto-print').checked;
|
||||
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());
|
||||
localStorage.setItem('seki_giro', document.getElementById('setting-giro').value.trim());
|
||||
localStorage.setItem('seki_address', document.getElementById('setting-address').value.trim());
|
||||
}
|
||||
|
||||
document.querySelectorAll('.receipt-biz-name').forEach(el => el.innerText = bizName);
|
||||
|
||||
document.querySelectorAll('.receipt-sii-info').forEach(el => el.style.display = showSii ? 'block' : 'none');
|
||||
document.querySelectorAll('.receipt-standard-info').forEach(el => el.style.display = showSii ? 'none' : 'block');
|
||||
|
||||
document.querySelectorAll('.receipt-title-box').forEach(el => {
|
||||
if (showSii) {
|
||||
el.classList.add('sii-box');
|
||||
el.style.margin = '10px 15%';
|
||||
} else {
|
||||
el.classList.remove('sii-box');
|
||||
el.style.margin = '10px 0';
|
||||
}
|
||||
});
|
||||
|
||||
if (showSii) {
|
||||
document.querySelectorAll('.receipt-rut').forEach(el => el.innerText = localStorage.getItem('seki_rut') || '-');
|
||||
document.querySelectorAll('.receipt-giro').forEach(el => el.innerText = localStorage.getItem('seki_giro') || '-');
|
||||
document.querySelectorAll('.receipt-address').forEach(el => el.innerText = localStorage.getItem('seki_address') || '-');
|
||||
}
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('settingsModal')).hide();
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
{% endmacro %}
|
||||
@@ -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.1</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
|
||||
@@ -15,12 +16,17 @@
|
||||
<a href="/sales" class="btn btn-sm {{ 'btn-primary' if active_page == 'sales' else 'btn-outline-primary' }}">
|
||||
<i class="bi bi-receipt me-1"></i>Ventas
|
||||
</a>
|
||||
<a href="/gastos" class="btn btn-sm {{ 'btn-warning' if active_page == 'gastos' else 'btn-outline-warning' }}">
|
||||
<i class="bi bi-wallet2 me-1"></i>Gastos
|
||||
</a>
|
||||
<a href="/dicom" class="btn btn-sm {{ 'btn-danger' if active_page == 'dicom' else 'btn-outline-danger' }}">
|
||||
<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>
|
||||
@@ -29,7 +35,12 @@
|
||||
<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>
|
||||
<button class="dropdown-item" onclick="bootstrap.Modal.getOrCreateInstance(document.getElementById('settingsModal')).show()">
|
||||
<i class="bi bi-gear-fill me-2"></i>Configuración
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
@@ -50,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>
|
||||
@@ -4,7 +4,25 @@
|
||||
{% block title %}Ventas{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!--HEAD-->
|
||||
<style>
|
||||
/* Burn the ugly arrows */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* Fix the weird focus line on the ticket input group */
|
||||
.ticket-group .input-group-text { border-right: none; }
|
||||
.ticket-group #ticket-filter { border-left: none; }
|
||||
.ticket-group #ticket-filter:focus {
|
||||
box-shadow: none;
|
||||
border-color: var(--border);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,46 +31,60 @@
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="discord-card p-3 shadow-sm text-center">
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">
|
||||
{% if selected_date %}Día Seleccionado{% else %}Ventas de Hoy{% endif %}
|
||||
</h6>
|
||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.daily }}">
|
||||
</h2>
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Hoy</h6>
|
||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.daily }}"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="discord-card p-3 shadow-sm text-center">
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Últimos 7
|
||||
Días</h6>
|
||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.week }}">
|
||||
</h2>
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Esta Semana</h6>
|
||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.week }}"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="discord-card p-3 shadow-sm text-center">
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Este Mes
|
||||
</h6>
|
||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.month }}">
|
||||
</h2>
|
||||
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Este Mes</h6>
|
||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.month }}"></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="discord-card p-3 shadow-sm">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h4 class="mb-0"><i class="bi bi-receipt-cutoff me-2"></i>Historial</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label for="date-filter" class="form-label mb-0 text-muted small d-none d-sm-block">Filtrar
|
||||
Día:</label>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
|
||||
<select id="payment-filter" class="form-select form-select-sm" onchange="applyFilters(1)"
|
||||
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);">
|
||||
<option value="">Cualquier Pago</option>
|
||||
<option value="efectivo" {% if selected_payment == 'efectivo' %}selected{% endif %}>Efectivo</option>
|
||||
<option value="tarjeta" {% if selected_payment == 'tarjeta' %}selected{% endif %}>Tarjeta</option>
|
||||
<option value="transferencia" {% if selected_payment == 'transferencia' %}selected{% endif %}>Transferencia</option>
|
||||
</select>
|
||||
|
||||
<input type="date" id="date-filter" class="form-control form-control-sm"
|
||||
style="background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
|
||||
value="{{ selected_date or '' }}" onchange="filterByDate(this.value)">
|
||||
{% if selected_date %}
|
||||
<a href="/sales" class="btn btn-sm btn-outline-secondary" title="Limpiar filtro"><i
|
||||
class="bi bi-x-lg"></i></a>
|
||||
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
|
||||
value="{{ selected_date or '' }}" onchange="applyFilters(1)">
|
||||
|
||||
{% if selected_date or selected_payment %}
|
||||
<a href="/sales" class="btn btn-sm btn-outline-danger px-2" title="Limpiar filtros"><i class="bi bi-x-lg"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if selected_date or selected_payment or selected_ticket %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 p-3 rounded shadow-sm"
|
||||
style="background: rgba(var(--accent-rgb, 88, 101, 242), 0.1); border: 1px dashed var(--accent, #5865F2);">
|
||||
<div>
|
||||
<h6 class="mb-0 text-uppercase" style="font-size: 0.75rem; font-weight: 700; color: var(--accent, #5865F2);">
|
||||
<i class="bi bi-funnel-fill me-1"></i> Total Filtrado
|
||||
</h6>
|
||||
<small class="text-muted">{{ filtered_stats.count }} ventas encontradas</small>
|
||||
</div>
|
||||
<h3 class="price-cell mb-0" style="color: var(--accent, #5865F2); font-weight: 800;" data-value="{{ filtered_stats.total }}"></h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
@@ -82,15 +114,32 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Navegación de páginas" class="mt-4">
|
||||
<ul class="pagination justify-content-center pagination-sm">
|
||||
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
|
||||
<button class="page-link" onclick="applyFilters({{ current_page - 1 }})" style="background: var(--input-bg); color: var(--text-main); border-color: var(--border);">Anterior</button>
|
||||
</li>
|
||||
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link" style="background: var(--bg-main); color: var(--text-muted); border-color: var(--border);">
|
||||
Página {{ current_page }} de {{ total_pages }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li class="page-item {% if current_page >= total_pages %}disabled{% endif %}">
|
||||
<button class="page-link" onclick="applyFilters({{ current_page + 1 }})" style="background: var(--input-bg); color: var(--text-main); border-color: var(--border);">Siguiente</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="receiptModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Detalle de Venta <span id="modal-ticket-id" class="text-muted small"></span>
|
||||
</h5>
|
||||
<h5 class="modal-title">Detalle de Venta <span id="modal-ticket-id" class="text-muted small"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -103,8 +152,7 @@
|
||||
<th class="text-end">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="receipt-items">
|
||||
</tbody>
|
||||
<tbody id="receipt-items"></tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="2" class="text-end">TOTAL:</th>
|
||||
@@ -113,8 +161,6 @@
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between border-0 pt-0">
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-danger btn-sm" id="btn-reverse-sale">
|
||||
@@ -124,7 +170,6 @@
|
||||
<i class="bi bi-printer me-1"></i>Re-imprimir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,6 +197,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
||||
@@ -227,7 +273,13 @@
|
||||
`).join('');
|
||||
|
||||
document.getElementById('receipt-ticket-id').innerText = id;
|
||||
|
||||
const neto = Math.round(total / 1.19);
|
||||
const iva = total - neto;
|
||||
document.getElementById('receipt-neto-print').innerText = clp.format(neto);
|
||||
document.getElementById('receipt-iva-print').innerText = clp.format(iva);
|
||||
document.getElementById('receipt-total-print').innerText = clp.format(total);
|
||||
|
||||
document.getElementById('receipt-date').innerText = new Date(rawDate + " UTC").toLocaleString('es-CL');
|
||||
document.getElementById('receipt-payment-info').style.display = 'none';
|
||||
|
||||
@@ -238,12 +290,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function filterByDate(dateVal) {
|
||||
if (dateVal) {
|
||||
window.location.href = `/sales?date=${dateVal}`;
|
||||
} else {
|
||||
window.location.href = `/sales`;
|
||||
function applyFilters(page = 1) {
|
||||
const dateVal = document.getElementById('date-filter').value;
|
||||
const paymentVal = document.getElementById('payment-filter').value;
|
||||
|
||||
const url = new URL(window.location.origin + '/sales');
|
||||
|
||||
if (dateVal) url.searchParams.set('date', dateVal);
|
||||
if (paymentVal) url.searchParams.set('payment_method', paymentVal);
|
||||
if (page > 1) url.searchParams.set('page', page);
|
||||
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function handleEnter(e) {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
}
|
||||
|
||||
function reverseSale(id) {
|
||||
|
||||
Reference in New Issue
Block a user