Compare commits
7 Commits
7f4b23efda
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| caf73ce156 | |||
| 83f9f606de | |||
| 4b3ef3eb8b | |||
| 656d1bb895 | |||
| c0a737915e | |||
| b9bcd49a0c | |||
| 47cc480cf5 |
42
README.md
42
README.md
@@ -1,4 +1,4 @@
|
|||||||
# SekiPOS v2.1 🍫🥤
|
# SekiPOS v2.2 🍫🥤
|
||||||
|
|
||||||
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.
|
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.
|
- **Secure:** Hashed password authentication via Flask-Login.
|
||||||
- **On device scanner:** Add and scan products from within your phone!
|
- **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)
|
## 🐳 Docker Deployment (Server)
|
||||||
|
|
||||||
Build and run the central inventory server:
|
Build and run the central inventory server:
|
||||||
@@ -35,6 +73,8 @@ services:
|
|||||||
sekipos:
|
sekipos:
|
||||||
ports:
|
ports:
|
||||||
- 5000:5000
|
- 5000:5000
|
||||||
|
environment:
|
||||||
|
- TZ=America/Santiago
|
||||||
volumes:
|
volumes:
|
||||||
- YOUR_PATH/sekipos/db:/app/db
|
- YOUR_PATH/sekipos/db:/app/db
|
||||||
- YOUR_PATH/sekipos/static/cache:/app/static/cache
|
- YOUR_PATH/sekipos/static/cache:/app/static/cache
|
||||||
|
|||||||
204
app.py
204
app.py
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import requests
|
import requests
|
||||||
from flask import send_file
|
from flask import send_file
|
||||||
@@ -12,6 +13,8 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import zipfile
|
import zipfile
|
||||||
import io
|
import io
|
||||||
|
import webview
|
||||||
|
import threading
|
||||||
|
|
||||||
# from dotenv import load_dotenv
|
# from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -20,16 +23,42 @@ import io
|
|||||||
# MP_ACCESS_TOKEN = os.getenv('MP_ACCESS_TOKEN')
|
# MP_ACCESS_TOKEN = os.getenv('MP_ACCESS_TOKEN')
|
||||||
# MP_TERMINAL_ID = os.getenv('MP_TERMINAL_ID')
|
# MP_TERMINAL_ID = os.getenv('MP_TERMINAL_ID')
|
||||||
|
|
||||||
app = Flask(__name__)
|
# --- PATH HELPERS FOR PYINSTALLER ---
|
||||||
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
|
def get_bundled_path(relative_path):
|
||||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
"""Path for read-only files packed inside the .exe (templates, static)"""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
else:
|
||||||
|
base_path = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
return os.path.join(base_path, relative_path)
|
||||||
|
|
||||||
# Auth Setup
|
def get_persistent_path(relative_path):
|
||||||
|
"""Path for read/write files that must survive reboots (db, cache)"""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
base_path = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
base_path = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
return os.path.join(base_path, relative_path)
|
||||||
|
|
||||||
|
# --- FLASK INIT ---
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=get_bundled_path('templates'),
|
||||||
|
static_folder=get_bundled_path('static')
|
||||||
|
)
|
||||||
|
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
||||||
|
|
||||||
|
# --- AUTH SETUP (Do not delete this) ---
|
||||||
login_manager = LoginManager(app)
|
login_manager = LoginManager(app)
|
||||||
login_manager.login_view = 'login'
|
login_manager.login_view = 'login'
|
||||||
|
|
||||||
DB_FILE = 'db/pos_database.db'
|
# --- DIRECTORY SETUP ---
|
||||||
CACHE_DIR = 'static/cache'
|
DB_DIR = get_persistent_path('db')
|
||||||
|
os.makedirs(DB_DIR, exist_ok=True)
|
||||||
|
DB_FILE = os.path.join(DB_DIR, "pos_database.db")
|
||||||
|
|
||||||
|
CACHE_DIR = get_persistent_path(os.path.join('static', 'cache'))
|
||||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
# --- MODELS ---
|
# --- MODELS ---
|
||||||
@@ -189,26 +218,80 @@ def dicom():
|
|||||||
@login_required
|
@login_required
|
||||||
def sales():
|
def sales():
|
||||||
selected_date = request.args.get('date')
|
selected_date = request.args.get('date')
|
||||||
|
payment_method = request.args.get('payment_method')
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 100
|
||||||
|
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# Determine the target date for the "Daily" stat
|
# 1. Calculate the top 3 cards (Now respecting the payment method!)
|
||||||
target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0]
|
target_date = selected_date if selected_date else cur.execute("SELECT date('now', 'localtime')").fetchone()[0]
|
||||||
|
|
||||||
|
daily_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?"
|
||||||
|
week_query = "SELECT SUM(total) FROM sales WHERE date(date, 'localtime') >= date('now', 'localtime', '-7 days')"
|
||||||
|
month_query = "SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = strftime('%Y-%m', 'now', 'localtime')"
|
||||||
|
|
||||||
|
daily_params = [target_date]
|
||||||
|
week_params = []
|
||||||
|
month_params = []
|
||||||
|
|
||||||
|
# If a payment method is selected, inject it into the top card queries
|
||||||
|
if payment_method:
|
||||||
|
daily_query += " AND payment_method = ?"
|
||||||
|
week_query += " AND payment_method = ?"
|
||||||
|
month_query += " AND payment_method = ?"
|
||||||
|
|
||||||
|
daily_params.append(payment_method)
|
||||||
|
week_params.append(payment_method)
|
||||||
|
month_params.append(payment_method)
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"daily": cur.execute("SELECT SUM(total) FROM sales WHERE date(date, 'localtime') = ?", (target_date,)).fetchone()[0] or 0,
|
"daily": cur.execute(daily_query, tuple(daily_params)).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,
|
"week": cur.execute(week_query, tuple(week_params)).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
|
"month": cur.execute(month_query, tuple(month_params)).fetchone()[0] or 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if selected_date:
|
# 2. Dynamic query builder for the main table and pagination
|
||||||
sales_data = cur.execute('''SELECT id, date, total, payment_method FROM sales
|
base_query = "FROM sales WHERE 1=1"
|
||||||
WHERE date(date, 'localtime') = ?
|
params = []
|
||||||
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)
|
if selected_date:
|
||||||
|
base_query += " AND date(date, 'localtime') = ?"
|
||||||
|
params.append(selected_date)
|
||||||
|
if payment_method:
|
||||||
|
base_query += " AND payment_method = ?"
|
||||||
|
params.append(payment_method)
|
||||||
|
|
||||||
|
# Get total count and sum for the current table filters BEFORE applying limit/offset
|
||||||
|
stats_query = f"SELECT COUNT(*), SUM(total) {base_query}"
|
||||||
|
count_res, sum_res = cur.execute(stats_query, tuple(params)).fetchone()
|
||||||
|
|
||||||
|
total_count = count_res or 0
|
||||||
|
total_sum = sum_res or 0
|
||||||
|
total_pages = (total_count + per_page - 1) // per_page
|
||||||
|
|
||||||
|
filtered_stats = {
|
||||||
|
"total": total_sum,
|
||||||
|
"count": total_count
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch the actual 100 rows for the current page
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
data_query = f"SELECT id, date, total, payment_method {base_query} ORDER BY date DESC LIMIT ? OFFSET ?"
|
||||||
|
|
||||||
|
sales_data = cur.execute(data_query, tuple(params) + (per_page, offset)).fetchall()
|
||||||
|
|
||||||
|
return render_template('sales.html',
|
||||||
|
active_page='sales',
|
||||||
|
user=current_user,
|
||||||
|
sales=sales_data,
|
||||||
|
stats=stats,
|
||||||
|
filtered_stats=filtered_stats,
|
||||||
|
selected_date=selected_date,
|
||||||
|
selected_payment=payment_method,
|
||||||
|
current_page=page,
|
||||||
|
total_pages=total_pages)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/upsert", methods=["POST"])
|
@app.route("/upsert", methods=["POST"])
|
||||||
@@ -560,6 +643,70 @@ def export_images():
|
|||||||
download_name=f"SekiPOS_Images_{datetime.now().strftime('%Y%m%d')}.zip"
|
download_name=f"SekiPOS_Images_{datetime.now().strftime('%Y%m%d')}.zip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@app.route('/gastos')
|
||||||
|
@login_required
|
||||||
|
def gastos():
|
||||||
|
# Default to the current month if no filter is applied
|
||||||
|
selected_month = request.args.get('month', datetime.now().strftime('%Y-%m'))
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Auto-create the table so it doesn't crash on first load
|
||||||
|
cur.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS expenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Calculate totals for the selected month
|
||||||
|
sales_total = cur.execute("SELECT SUM(total) FROM sales WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
|
||||||
|
expenses_total = cur.execute("SELECT SUM(amount) FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ?", (selected_month,)).fetchone()[0] or 0
|
||||||
|
|
||||||
|
# Fetch the expense list
|
||||||
|
expenses_list = cur.execute("SELECT id, date, description, amount FROM expenses WHERE strftime('%Y-%m', date, 'localtime') = ? ORDER BY date DESC", (selected_month,)).fetchall()
|
||||||
|
|
||||||
|
return render_template('gastos.html',
|
||||||
|
active_page='gastos',
|
||||||
|
user=current_user,
|
||||||
|
sales_total=sales_total,
|
||||||
|
expenses_total=expenses_total,
|
||||||
|
net_profit=sales_total - expenses_total,
|
||||||
|
expenses=expenses_list,
|
||||||
|
selected_month=selected_month)
|
||||||
|
|
||||||
|
@app.route('/api/gastos', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_gasto():
|
||||||
|
data = request.get_json()
|
||||||
|
desc = data.get('description')
|
||||||
|
amount = data.get('amount')
|
||||||
|
|
||||||
|
if not desc or not amount:
|
||||||
|
return jsonify({"error": "Faltan datos"}), 400
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("INSERT INTO expenses (description, amount) VALUES (?, ?)", (desc, int(amount)))
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
@app.route('/api/gastos/<int:gasto_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def delete_gasto(gasto_id):
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM expenses WHERE id = ?", (gasto_id,))
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
# @app.route('/process_payment', methods=['POST'])
|
# @app.route('/process_payment', methods=['POST'])
|
||||||
# @login_required
|
# @login_required
|
||||||
# def process_payment():
|
# def process_payment():
|
||||||
@@ -635,6 +782,29 @@ def export_images():
|
|||||||
# })
|
# })
|
||||||
# return jsonify({"status": "ok"}), 200
|
# return jsonify({"status": "ok"}), 200
|
||||||
|
|
||||||
|
def start_server():
|
||||||
|
# Use socketio.run instead of default app.run
|
||||||
|
socketio.run(app, host='127.0.0.1', port=5000)
|
||||||
|
|
||||||
|
def run_standalone():
|
||||||
|
t = threading.Thread(target=start_server)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
# GIVE FLASK 2 SECONDS TO BOOT UP BEFORE OPENING THE BROWSER
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
webview.create_window('SekiPOS', 'http://127.0.0.1:5000', width=1366, height=768, resizable=True, fullscreen=False, min_size=(800, 600), maximized=True)
|
||||||
|
|
||||||
|
# private_mode=False is the magic flag that allows localStorage to survive.
|
||||||
|
# It saves data to %APPDATA%\pywebview on Windows.
|
||||||
|
webview.start(private_mode=False)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_db()
|
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)
|
socketio.run(app, host='0.0.0.0', port=5000, debug=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=True,
|
||||||
|
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',
|
||||||
|
)
|
||||||
2
build/.gitignore
vendored
Normal file
2
build/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
2
dist/.gitignore
vendored
Normal file
2
dist/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
{% extends "macros/base.html" %}
|
{% extends "macros/base.html" %}
|
||||||
{% from 'macros/modals.html' import confirm_modal, scanner_modal, render_receipt %}
|
{% from 'macros/modals.html' import confirm_modal, scanner_modal, render_receipt %}
|
||||||
|
|
||||||
{% block title %}Caja{% endblock %}
|
{% block title %}Caja{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
@@ -130,8 +129,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="paymentModal" tabindex="-1">
|
<div class="modal fade" id="orderDetailsModal" tabindex="-1" data-bs-backdrop="static">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
<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">
|
||||||
|
Detalles del Pedido
|
||||||
|
</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="form-label text-muted small mb-1">Nombre del Cliente</label>
|
||||||
|
<input type="text" id="order-client-name" class="form-control" placeholder="Ej: Juan Pérez" autocomplete="off" onkeydown="if(event.key === 'Enter') document.getElementById('order-pickup-time').focus()">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-muted small mb-1">Hora de Retiro (Opcional)</label>
|
||||||
|
<input type="time" id="order-pickup-time" class="form-control" onkeydown="if(event.key === 'Enter') document.getElementById('order-notes').focus()">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label text-muted small mb-1">Detalles / Notas</label>
|
||||||
|
<input type="text" id="order-notes" class="form-control" placeholder="Ej: Para llevar, Sin cebolla" autocomplete="off" onkeydown="if(event.key === 'Enter') confirmOrderDetails()">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100 py-2 fw-bold" onclick="confirmOrderDetails()">
|
||||||
|
Continuar al Pago <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="paymentModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header border-0 pb-0">
|
<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">
|
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">
|
||||||
@@ -143,10 +172,13 @@
|
|||||||
<h1 id="payment-modal-total" class="mb-4" style="color: var(--accent); font-weight: 800; font-size: 3rem">$0</h1>
|
<h1 id="payment-modal-total" class="mb-4" style="color: var(--accent); font-weight: 800; font-size: 3rem">$0</h1>
|
||||||
<div class="d-grid gap-3 px-3">
|
<div class="d-grid gap-3 px-3">
|
||||||
<button class="btn btn-lg btn-success py-3" onclick="openVueltoModal()">
|
<button class="btn btn-lg btn-success py-3" onclick="openVueltoModal()">
|
||||||
<i class="bi bi-cash-coin me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Efectivo
|
<i class="bi bi-cash-coin me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Efectivo (1)
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-lg btn-secondary py-3" onclick="executeCheckout('tarjeta')">
|
<button class="btn btn-lg btn-secondary py-3" onclick="executeCheckout('tarjeta')">
|
||||||
<i class="bi bi-credit-card me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Tarjeta (Pronto)
|
<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="executeCheckout('transferencia')">
|
||||||
|
<i class="bi bi-bank me-2" style="font-size: 1.5rem; vertical-align: middle"></i> Transferencia (3)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,11 +200,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3 text-start">
|
<div class="mb-3 text-start">
|
||||||
<label class="text-muted small mb-1">Monto Recibido</label>
|
<label class="text-muted small mb-1">Monto Recibido</label>
|
||||||
<input type="number" id="monto-recibido" class="form-control form-control-lg text-center fw-bold fs-4"
|
<input type="text" inputmode="numeric" id="monto-recibido" class="form-control form-control-lg text-center fw-bold fs-4"
|
||||||
placeholder="$0" onkeyup="calculateVuelto()" onchange="calculateVuelto()"
|
placeholder="$0"
|
||||||
|
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : ''; calculateVuelto();"
|
||||||
onkeydown="if(event.key === 'Enter' && !document.getElementById('btn-confirm-vuelto').disabled) executeCheckout('efectivo')">
|
onkeydown="if(event.key === 'Enter' && !document.getElementById('btn-confirm-vuelto').disabled) executeCheckout('efectivo')">
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-center gap-2 mb-3" id="vuelto-quick-buttons"></div>
|
<div class="d-flex flex-wrap justify-content-center gap-2 mb-3" id="vuelto-quick-buttons"></div>
|
||||||
<div class="p-3 mb-3" style="background: var(--input-bg); border-radius: 8px">
|
<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 class="text-muted small text-uppercase fw-bold">Vuelto a Entregar</span><br>
|
||||||
<span id="vuelto-amount" class="fs-1 fw-bold text-muted">$0</span>
|
<span id="vuelto-amount" class="fs-1 fw-bold text-muted">$0</span>
|
||||||
@@ -284,6 +317,7 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="discord-card p-3">
|
<div class="discord-card p-3">
|
||||||
<h4><i class="bi bi-cart3"></i> Carrito</h4>
|
<h4><i class="bi bi-cart3"></i> Carrito</h4>
|
||||||
|
|
||||||
<div class="position-relative mb-4">
|
<div class="position-relative mb-4">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text border-0 position-absolute" style="background: transparent; z-index: 10">
|
<span class="input-group-text border-0 position-absolute" style="background: transparent; z-index: 10">
|
||||||
@@ -303,6 +337,9 @@
|
|||||||
<div id="search-results" class="dropdown-menu w-100 shadow-sm mt-1"
|
<div id="search-results" class="dropdown-menu w-100 shadow-sm mt-1"
|
||||||
style="display: none; position: absolute; top: 100%; left: 0; z-index: 1000; max-height: 300px; overflow-y: auto"></div>
|
style="display: none; position: absolute; top: 100%; left: 0; z-index: 1000; max-height: 300px; overflow-y: auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pinned-products-container" class="d-flex flex-wrap gap-2 mb-3"></div>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table mt-3" id="cart-table">
|
<table class="table mt-3" id="cart-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -360,6 +397,16 @@
|
|||||||
let editingCartIndex = null;
|
let editingCartIndex = null;
|
||||||
let itemIndexToRemove = null;
|
let itemIndexToRemove = null;
|
||||||
|
|
||||||
|
let currentClientName = '';
|
||||||
|
let currentOrderNotes = '';
|
||||||
|
let currentPickupTime = '';
|
||||||
|
|
||||||
|
// The lock to prevent duplicate sales
|
||||||
|
let isProcessing = false;
|
||||||
|
|
||||||
|
// Fetch the pinned items from local storage
|
||||||
|
let pinnedBarcodes = JSON.parse(localStorage.getItem('seki_pinned_products')) || [];
|
||||||
|
|
||||||
let socket = io();
|
let socket = io();
|
||||||
|
|
||||||
const clp = new Intl.NumberFormat('es-CL', {
|
const clp = new Intl.NumberFormat('es-CL', {
|
||||||
@@ -550,22 +597,65 @@
|
|||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
resultsBox.innerHTML = '<div class="p-3 text-muted text-center">No se encontraron productos</div>';
|
resultsBox.innerHTML = '<div class="p-3 text-muted text-center">No se encontraron productos</div>';
|
||||||
} else {
|
} else {
|
||||||
resultsBox.innerHTML = matches.map(p => `
|
resultsBox.innerHTML = matches.map(p => {
|
||||||
|
// Check if it's pinned to color the icon
|
||||||
|
const isPinned = pinnedBarcodes.includes(p.barcode);
|
||||||
|
const pinIcon = isPinned ? 'bi-pin-angle-fill text-warning' : 'bi-pin-angle text-muted';
|
||||||
|
|
||||||
|
return `
|
||||||
<a href="#" class="dropdown-item d-flex justify-content-between align-items-center py-2" onclick="selectSearchResult('${p.barcode}')">
|
<a href="#" class="dropdown-item d-flex justify-content-between align-items-center py-2" onclick="selectSearchResult('${p.barcode}')">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="togglePin('${p.barcode}', event)" title="Fijar producto">
|
||||||
|
<i class="bi ${pinIcon} fs-5"></i>
|
||||||
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<strong>${p.name}</strong><br>
|
<strong>${p.name}</strong><br>
|
||||||
<small class="text-muted font-monospace">${p.barcode}</small>
|
<small class="text-muted font-monospace">${p.barcode}</small>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span style="color: var(--accent); font-weight: bold;">${clp.format(p.price)}</span><br>
|
<span style="color: var(--accent); font-weight: bold;">${clp.format(p.price)}</span><br>
|
||||||
<small class="text-muted">${p.unit === 'kg' ? 'Kg' : 'Unidad'}</small>
|
<small class="text-muted">${p.unit === 'kg' ? 'Kg' : 'Unidad'}</small>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
`).join('');
|
`}).join('');
|
||||||
}
|
}
|
||||||
resultsBox.style.display = 'block';
|
resultsBox.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePin(barcode, event) {
|
||||||
|
event.stopPropagation(); // Stop the row from adding to cart when clicking the pin
|
||||||
|
if (pinnedBarcodes.includes(barcode)) {
|
||||||
|
pinnedBarcodes = pinnedBarcodes.filter(b => b !== barcode);
|
||||||
|
} else {
|
||||||
|
pinnedBarcodes.push(barcode);
|
||||||
|
}
|
||||||
|
localStorage.setItem('seki_pinned_products', JSON.stringify(pinnedBarcodes));
|
||||||
|
|
||||||
|
renderPinnedProducts();
|
||||||
|
filterSearch(); // Re-render the search dropdown so the pin icon updates
|
||||||
|
document.getElementById('manual-search').focus(); // Keep focus on search input
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPinnedProducts() {
|
||||||
|
const container = document.getElementById('pinned-products-container');
|
||||||
|
const pinnedProducts = allProducts.filter(p => pinnedBarcodes.includes(p.barcode));
|
||||||
|
|
||||||
|
if (pinnedProducts.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = pinnedProducts.map(p => `
|
||||||
|
<button class="btn btn-outline-secondary text-start p-2 shadow-sm d-flex flex-column justify-content-between"
|
||||||
|
style="width: 110px; height: 75px; border-color: var(--border); background: var(--input-bg); color: var(--text-main);"
|
||||||
|
onclick="handleProductScan(allProducts.find(x => x.barcode === '${p.barcode}'))">
|
||||||
|
<span class="small fw-bold text-truncate w-100 mb-1" title="${p.name}">${p.name}</span>
|
||||||
|
<span class="small" style="color: var(--accent); font-weight: 800;">${clp.format(p.price)}</span>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function selectSearchResult(barcode) {
|
function selectSearchResult(barcode) {
|
||||||
const product = allProducts.find(p => p.barcode === barcode);
|
const product = allProducts.find(p => p.barcode === barcode);
|
||||||
if (product) handleProductScan(product);
|
if (product) handleProductScan(product);
|
||||||
@@ -708,6 +798,31 @@
|
|||||||
|
|
||||||
if (total === 666) { triggerDoom(); return; }
|
if (total === 666) { triggerDoom(); return; }
|
||||||
|
|
||||||
|
// Intercept checkout if Restaurant mode is active
|
||||||
|
if (localStorage.getItem('seki_ask_order_details') === 'true') {
|
||||||
|
document.getElementById('order-client-name').value = '';
|
||||||
|
document.getElementById('order-pickup-time').value = ''; // <-- ADD THIS
|
||||||
|
document.getElementById('order-notes').value = '';
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('orderDetailsModal')).show();
|
||||||
|
setTimeout(() => document.getElementById('order-client-name').focus(), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPaymentModal(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmOrderDetails() {
|
||||||
|
currentClientName = document.getElementById('order-client-name').value.trim();
|
||||||
|
currentPickupTime = document.getElementById('order-pickup-time').value;
|
||||||
|
currentOrderNotes = document.getElementById('order-notes').value.trim();
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('orderDetailsModal')).hide();
|
||||||
|
|
||||||
|
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
||||||
|
showPaymentModal(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPaymentModal(total) {
|
||||||
document.getElementById('payment-modal-total').innerText = clp.format(total);
|
document.getElementById('payment-modal-total').innerText = clp.format(total);
|
||||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
|
||||||
}
|
}
|
||||||
@@ -737,13 +852,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setMonto(amount) {
|
function setMonto(amount) {
|
||||||
document.getElementById('monto-recibido').value = amount;
|
// Formats the quick-select buttons so they have dots too
|
||||||
|
document.getElementById('monto-recibido').value = amount.toLocaleString('es-CL');
|
||||||
calculateVuelto();
|
calculateVuelto();
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateVuelto() {
|
function calculateVuelto() {
|
||||||
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
||||||
const recibido = parseInt(document.getElementById('monto-recibido').value, 10);
|
// Strip the dots before parsing the math
|
||||||
|
const rawRecibido = document.getElementById('monto-recibido').value.replace(/\./g, '');
|
||||||
|
const recibido = parseInt(rawRecibido, 10);
|
||||||
const vueltoDisplay = document.getElementById('vuelto-amount');
|
const vueltoDisplay = document.getElementById('vuelto-amount');
|
||||||
const confirmBtn = document.getElementById('btn-confirm-vuelto');
|
const confirmBtn = document.getElementById('btn-confirm-vuelto');
|
||||||
|
|
||||||
@@ -763,13 +881,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeCheckout(method) {
|
async function executeCheckout(method) {
|
||||||
if (cart.length === 0) return;
|
if (cart.length === 0 || isProcessing) return;
|
||||||
|
isProcessing = true;
|
||||||
|
|
||||||
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
const total = cart.reduce((sum, item) => sum + item.subtotal, 0);
|
||||||
let paidAmount = total;
|
let paidAmount = total;
|
||||||
|
|
||||||
|
const confirmBtn = document.getElementById('btn-confirm-vuelto');
|
||||||
|
const originalBtnText = confirmBtn.innerHTML;
|
||||||
|
|
||||||
if (method === 'efectivo') {
|
if (method === 'efectivo') {
|
||||||
const inputVal = parseInt(document.getElementById('monto-recibido').value, 10);
|
// Strip the dots here too
|
||||||
|
const rawVal = document.getElementById('monto-recibido').value.replace(/\./g, '');
|
||||||
|
const inputVal = parseInt(rawVal, 10);
|
||||||
if (!isNaN(inputVal) && inputVal > 0) paidAmount = inputVal;
|
if (!isNaN(inputVal) && inputVal > 0) paidAmount = inputVal;
|
||||||
|
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Procesando...';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -793,8 +921,15 @@
|
|||||||
renderCart();
|
renderCart();
|
||||||
clearLastScanned();
|
clearLastScanned();
|
||||||
setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000);
|
setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000);
|
||||||
} else { alert("Error: " + (result.error || "Error desconocido")); }
|
} else {
|
||||||
} catch (err) { alert("Error de conexión."); }
|
alert("Error: " + (result.error || "Error desconocido"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Error de conexión.");
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
confirmBtn.innerHTML = originalBtnText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openQuickSaleModal() {
|
function openQuickSaleModal() {
|
||||||
@@ -805,7 +940,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function processQuickSale() {
|
async function processQuickSale() {
|
||||||
// Strip the dots before asking JS to do math
|
if (isProcessing) return;
|
||||||
|
|
||||||
const rawValue = document.getElementById('quick-sale-amount').value.replace(/\./g, '');
|
const rawValue = document.getElementById('quick-sale-amount').value.replace(/\./g, '');
|
||||||
const amount = parseInt(rawValue, 10);
|
const amount = parseInt(rawValue, 10);
|
||||||
|
|
||||||
@@ -816,6 +952,12 @@
|
|||||||
triggerDoom(); return;
|
triggerDoom(); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
const quickBtn = document.querySelector('#quickSaleModal .btn-primary');
|
||||||
|
const originalBtnText = quickBtn.innerHTML;
|
||||||
|
quickBtn.disabled = true;
|
||||||
|
quickBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Procesando...';
|
||||||
|
|
||||||
const quickCart = [{
|
const quickCart = [{
|
||||||
barcode: `RAPIDA-${Date.now().toString().slice(-6)}`,
|
barcode: `RAPIDA-${Date.now().toString().slice(-6)}`,
|
||||||
name: '* Varios', price: amount, qty: 1, subtotal: amount, unit: 'unit'
|
name: '* Varios', price: amount, qty: 1, subtotal: amount, unit: 'unit'
|
||||||
@@ -838,8 +980,16 @@
|
|||||||
|
|
||||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal')).show();
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('successModal')).show();
|
||||||
setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000);
|
setTimeout(() => bootstrap.Modal.getInstance(document.getElementById('successModal')).hide(), 2000);
|
||||||
} else { alert("Error: " + (result.error || "Error desconocido")); }
|
} else {
|
||||||
} catch (err) { alert("Error de conexión."); }
|
alert("Error: " + (result.error || "Error desconocido"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Error de conexión.");
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
quickBtn.disabled = false;
|
||||||
|
quickBtn.innerHTML = originalBtnText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printReceipt(total, saleId, paidAmount = 0) {
|
function printReceipt(total, saleId, paidAmount = 0) {
|
||||||
@@ -858,13 +1008,50 @@
|
|||||||
|
|
||||||
const finalPaid = paidAmount > 0 ? paidAmount : total;
|
const finalPaid = paidAmount > 0 ? paidAmount : total;
|
||||||
|
|
||||||
|
// Calculate Neto & IVA (Assuming 19% IVA)
|
||||||
|
const neto = Math.round(total / 1.19);
|
||||||
|
const iva = total - neto;
|
||||||
|
|
||||||
document.getElementById('receipt-ticket-id').innerText = saleId || "N/A";
|
document.getElementById('receipt-ticket-id').innerText = saleId || "N/A";
|
||||||
|
|
||||||
|
// Add the Neto and IVA fields
|
||||||
|
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-total-print').innerText = clp.format(total);
|
||||||
document.getElementById('receipt-paid-print').innerText = clp.format(finalPaid);
|
document.getElementById('receipt-paid-print').innerText = clp.format(finalPaid);
|
||||||
document.getElementById('receipt-change-print').innerText = clp.format(finalPaid - total);
|
document.getElementById('receipt-change-print').innerText = clp.format(finalPaid - total);
|
||||||
document.getElementById('receipt-date').innerText = new Date().toLocaleString('es-CL');
|
document.getElementById('receipt-date').innerText = new Date().toLocaleString('es-CL');
|
||||||
|
|
||||||
setTimeout(() => window.print(), 250);
|
// NEW: Fill in Restaurant Info if it exists
|
||||||
|
const orderInfoDiv = document.getElementById('receipt-order-info');
|
||||||
|
if (currentClientName || currentOrderNotes || currentPickupTime) {
|
||||||
|
orderInfoDiv.style.display = 'block';
|
||||||
|
document.getElementById('receipt-client-name').innerText = currentClientName || '-';
|
||||||
|
document.getElementById('receipt-order-notes').innerText = currentOrderNotes || '-';
|
||||||
|
|
||||||
|
const pickupContainer = document.getElementById('receipt-pickup-container');
|
||||||
|
if (currentPickupTime) {
|
||||||
|
pickupContainer.style.display = 'block';
|
||||||
|
document.getElementById('receipt-pickup-time').innerText = currentPickupTime;
|
||||||
|
} else {
|
||||||
|
pickupContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
orderInfoDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wipe the memory for the next sale
|
||||||
|
currentClientName = '';
|
||||||
|
currentOrderNotes = '';
|
||||||
|
currentPickupTime = '';
|
||||||
|
|
||||||
|
// Check the setting before printing
|
||||||
|
setTimeout(() => {
|
||||||
|
if (localStorage.getItem('seki_auto_print') !== 'false') {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
@@ -897,7 +1084,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Payment Modal Selection: 1 for Efectivo, 2 for Tarjeta
|
// 2. Payment Modal Selection: 1 for Efectivo, 2 for Tarjeta, 3 for Transferencia
|
||||||
if (openModal && openModal.id === 'paymentModal') {
|
if (openModal && openModal.id === 'paymentModal') {
|
||||||
if (event.code === 'Numpad1' || event.key === '1') {
|
if (event.code === 'Numpad1' || event.key === '1') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -909,6 +1096,12 @@
|
|||||||
executeCheckout('tarjeta');
|
executeCheckout('tarjeta');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// NEW TRANSFER SHORTCUT
|
||||||
|
if (event.code === 'Numpad3' || event.key === '3') {
|
||||||
|
event.preventDefault();
|
||||||
|
executeCheckout('transferencia');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. The Money Button: Enter
|
// 3. The Money Button: Enter
|
||||||
@@ -1062,6 +1255,7 @@
|
|||||||
10. INIT
|
10. INIT
|
||||||
========================================= */
|
========================================= */
|
||||||
loadCart();
|
loadCart();
|
||||||
|
renderPinnedProducts();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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 %}
|
||||||
@@ -22,6 +22,10 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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="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='cookieStuff.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
|
<script src="{{ url_for('static', filename='themeStuff.js') }}"></script>
|
||||||
|
|||||||
@@ -49,80 +49,283 @@
|
|||||||
<div id="receipt-print-zone{{ id_suffix }}" class="d-none d-print-block">
|
<div id="receipt-print-zone{{ id_suffix }}" class="d-none d-print-block">
|
||||||
<style>
|
<style>
|
||||||
@media print {
|
@media print {
|
||||||
/* Tell the browser this is a continuous 80mm thermal roll */
|
@page { margin: 0; size: 80mm auto; }
|
||||||
@page {
|
nav, .discord-card, .modal, .row { display: none !important; }
|
||||||
margin: 0;
|
body * { visibility: hidden; }
|
||||||
size: 80mm auto;
|
#receipt-print-zone{{ id_suffix }}, #receipt-print-zone{{ id_suffix }} * { visibility: visible; }
|
||||||
}
|
|
||||||
|
|
||||||
/* Nuke the rest of the layout from the document flow so it takes up 0 height */
|
|
||||||
nav, .discord-card, .modal, .row {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body * {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 }} {
|
#receipt-print-zone{{ id_suffix }} {
|
||||||
position: absolute;
|
position: absolute; left: 0; top: 0; width: 80mm;
|
||||||
left: 0;
|
padding: 2mm 5mm; margin: 0; display: block !important;
|
||||||
top: 0;
|
font-family: 'Courier New', Courier, monospace; font-size: 11px; color: #000;
|
||||||
width: 80mm;
|
|
||||||
padding: 2mm 5mm;
|
|
||||||
margin: 0;
|
|
||||||
display: block !important;
|
|
||||||
font-family: 'Courier New', Courier, monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #000;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.receipt-table { width: 100%; border-collapse: collapse; font-family: 'Courier New', Courier, monospace; font-size: 11px; margin-top: 10px;}
|
||||||
.receipt-table { width: 100%; border-collapse: collapse; font-family: monospace; font-size: 12px; }
|
.receipt-header { text-align: center; margin-bottom: 5px; }
|
||||||
.receipt-header { text-align: center; margin-bottom: 10px; border-bottom: 1px dashed #000; padding-bottom: 5px; }
|
.sii-box { border: 2px solid #000; padding: 5px; text-align: center; font-weight: bold; }
|
||||||
.receipt-total-row { border-top: 1px dashed #000; margin-top: 5px; padding-top: 5px; 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>
|
</style>
|
||||||
|
|
||||||
<div class="receipt-header">
|
<div class="receipt-header">
|
||||||
<h3 style="margin: 0; font-weight: 800;">SekiPOS</h3>
|
<h3 style="margin: 0; font-weight: 800; font-size: 16px;" class="receipt-biz-name text-uppercase">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;">
|
<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;">
|
||||||
Ticket Nº <span id="receipt-ticket-id{{ id_suffix }}"></span>
|
Ticket Nº <span id="receipt-ticket-id{{ id_suffix }}"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="receipt-date{{ id_suffix }}" style="font-size: 11px;"></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>
|
</div>
|
||||||
|
|
||||||
<table class="receipt-table">
|
<table class="receipt-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr style="border-bottom: 1px dashed #000; border-top: 1px dashed #000;">
|
||||||
<th style="width: 15%; text-align: left;">Cant</th>
|
<th style="width: 15%; text-align: left; padding: 2px 0;">Cant</th>
|
||||||
<th style="width: 60%; padding-left: 5px; text-align: left;">Desc</th>
|
<th style="width: 55%; padding-left: 5px; text-align: left;">Articulo</th>
|
||||||
<th style="width: 25%; text-align: right;">Total</th>
|
<th style="width: 30%; text-align: right;">Total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="receipt-items-print{{ id_suffix }}"></tbody>
|
<tbody id="receipt-items-print{{ id_suffix }}"></tbody>
|
||||||
</table>
|
</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>TOTAL:</span>
|
||||||
<span id="receipt-total-print{{ id_suffix }}"></span>
|
<span id="receipt-total-print{{ id_suffix }}"></span>
|
||||||
</div>
|
</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">
|
<div class="d-flex justify-content-between">
|
||||||
<span>RECIBIDO:</span>
|
<span>Efectivo:</span>
|
||||||
<span id="receipt-paid-print{{ id_suffix }}"></span>
|
<span id="receipt-paid-print{{ id_suffix }}"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<span>VUELTO:</span>
|
<span>Vuelto:</span>
|
||||||
<span id="receipt-change-print{{ id_suffix }}"></span>
|
<span id="receipt-change-print{{ id_suffix }}"></span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{% endmacro %}
|
{% 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">
|
||||||
|
Solicitar Nombre/Notas al cobrar (Modo Comida)
|
||||||
|
</label>
|
||||||
|
</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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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,7 +1,7 @@
|
|||||||
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
|
||||||
<span class="navbar-brand">
|
<span class="navbar-brand">
|
||||||
SekiPOS
|
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;">v2.2</small>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="ms-3 gap-2 d-flex">
|
<div class="ms-3 gap-2 d-flex">
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
<a href="/sales" class="btn btn-sm {{ 'btn-primary' if active_page == 'sales' else 'btn-outline-primary' }}">
|
<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
|
<i class="bi bi-receipt me-1"></i>Ventas
|
||||||
</a>
|
</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' }}">
|
<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
|
<i class="bi bi-journal-x me-1"></i>Dicom
|
||||||
</a>
|
</a>
|
||||||
@@ -32,6 +35,11 @@
|
|||||||
<i class="bi bi-moon-stars me-2"></i>Modo Oscuro
|
<i class="bi bi-moon-stars me-2"></i>Modo Oscuro
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<a class="dropdown-item" href="/export/db">
|
<a class="dropdown-item" href="/export/db">
|
||||||
<i class="bi bi-database-down me-2"></i>Descargar DB
|
<i class="bi bi-database-down me-2"></i>Descargar DB
|
||||||
|
|||||||
@@ -4,7 +4,25 @@
|
|||||||
{% block title %}Ventas{% endblock %}
|
{% block title %}Ventas{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -13,46 +31,60 @@
|
|||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<div class="discord-card p-3 shadow-sm text-center">
|
<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;">
|
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Hoy</h6>
|
||||||
{% if selected_date %}Día Seleccionado{% else %}Ventas de Hoy{% endif %}
|
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.daily }}"></h2>
|
||||||
</h6>
|
|
||||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.daily }}">
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<div class="discord-card p-3 shadow-sm text-center">
|
<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
|
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Esta Semana</h6>
|
||||||
Días</h6>
|
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.week }}"></h2>
|
||||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.week }}">
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<div class="discord-card p-3 shadow-sm text-center">
|
<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 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Este Mes</h6>
|
||||||
</h6>
|
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.month }}"></h2>
|
||||||
<h2 class="price-cell mb-0" style="color: var(--accent); font-weight: 800;" data-value="{{ stats.month }}">
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="discord-card p-3 shadow-sm">
|
<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>
|
<h4 class="mb-0"><i class="bi bi-receipt-cutoff me-2"></i>Historial</h4>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<label for="date-filter" class="form-label mb-0 text-muted small d-none d-sm-block">Filtrar
|
|
||||||
Día:</label>
|
<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"
|
<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);"
|
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
|
||||||
value="{{ selected_date or '' }}" onchange="filterByDate(this.value)">
|
value="{{ selected_date or '' }}" onchange="applyFilters(1)">
|
||||||
{% if selected_date %}
|
|
||||||
<a href="/sales" class="btn btn-sm btn-outline-secondary" title="Limpiar filtro"><i
|
{% if selected_date or selected_payment %}
|
||||||
class="bi bi-x-lg"></i></a>
|
<a href="/sales" class="btn btn-sm btn-outline-danger px-2" title="Limpiar filtros"><i class="bi bi-x-lg"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -82,15 +114,32 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="modal fade" id="receiptModal" tabindex="-1">
|
<div class="modal fade" id="receiptModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">Detalle de Venta <span id="modal-ticket-id" class="text-muted small"></span>
|
<h5 class="modal-title">Detalle de Venta <span id="modal-ticket-id" class="text-muted small"></span></h5>
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -103,8 +152,7 @@
|
|||||||
<th class="text-end">Subtotal</th>
|
<th class="text-end">Subtotal</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="receipt-items">
|
<tbody id="receipt-items"></tbody>
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" class="text-end">TOTAL:</th>
|
<th colspan="2" class="text-end">TOTAL:</th>
|
||||||
@@ -113,8 +161,6 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer d-flex justify-content-between border-0 pt-0">
|
<div class="modal-footer d-flex justify-content-between border-0 pt-0">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-outline-danger btn-sm" id="btn-reverse-sale">
|
<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
|
<i class="bi bi-printer me-1"></i>Re-imprimir
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +197,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
||||||
@@ -238,12 +284,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByDate(dateVal) {
|
function applyFilters(page = 1) {
|
||||||
if (dateVal) {
|
const dateVal = document.getElementById('date-filter').value;
|
||||||
window.location.href = `/sales?date=${dateVal}`;
|
const paymentVal = document.getElementById('payment-filter').value;
|
||||||
} else {
|
|
||||||
window.location.href = `/sales`;
|
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) {
|
function reverseSale(id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user