Compare commits
116 Commits
3198696c46
...
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 | |||
| 7f4b23efda | |||
| b2bc0801f5 | |||
| cc6fc28c4a | |||
| 89f93f2638 | |||
| 53e28b15d9 | |||
| 1e44efda5e | |||
| 48632ae058 | |||
| 712294fc2e | |||
| a417715ff4 | |||
| ea3633518a | |||
| 0fcb8ce473 | |||
| 9cf0792866 | |||
| 719e227ba4 | |||
| ace45b1cc9 | |||
| 0bd47658e9 | |||
| 97a592b0c9 | |||
| 4bbbb0334c | |||
| 27d5fb26d9 | |||
| 5119d4bb31 | |||
| acaf537f11 | |||
| 55ff314163 | |||
| 376b8c54a6 | |||
| 1a048a0e07 | |||
| 77fc5920a2 | |||
| 78d48db9ea | |||
| 92e3a3f0f9 | |||
| 57cb27f6cf | |||
| c24dae9694 | |||
| e0ac23a8e0 | |||
| c1a06dc44c | |||
| 72f6e0c822 | |||
| b2418d8c7e | |||
| 9cb057668b | |||
| cb2aa89b16 | |||
| ef9a9296dd | |||
| 3c4b2e148d | |||
| bf1bc84cd0 | |||
| 8e37f9e776 | |||
| 216abc8ad2 | |||
| cffa3d789b | |||
| d7ef1573e5 | |||
| e101833c7d | |||
| 6c98919c80 | |||
| cae35a266f | |||
| c57e8ab6db | |||
| 135b14adcf | |||
| 9f59e122ef | |||
| 43cc2a3caa | |||
|
|
2f2998b0fd | ||
| 788b67804e | |||
| 2bb38570f9 | |||
| aacbce2557 | |||
| 6c5085093d | |||
| 423d563cc0 | |||
| 5e79b6938c | |||
| dcd14f1021 | |||
| b4344361e4 | |||
| fcb75cb5a4 | |||
| 676f299796 | |||
| 751c77cac5 | |||
| 85b2c0b4db | |||
| 741690b30e | |||
| 7235c7ff09 | |||
| 8cba7937c3 | |||
| 4779452acd | |||
| 600df52b04 | |||
| b1b99bc887 | |||
| c7c0b3feb2 | |||
| 184f2722bf | |||
| 0f9966d224 | |||
| 43b2a9e2d5 | |||
| 3a39cb95db | |||
| 81cacd3589 | |||
| 1f521ec1d2 | |||
| 0dcf0bc930 | |||
| df4ff9171d | |||
| 1b2e63bc86 | |||
| 80bf539484 | |||
| 13bba33c26 | |||
| ecd98c72ce | |||
| 344229b77b | |||
| 9a28daa2cd | |||
| ebf8bc72aa | |||
| 2ef510358d | |||
| d492905e57 | |||
| 7a4d122976 | |||
| 8cc5138888 | |||
| 3f47b3cda4 | |||
| c1045b4878 | |||
| 70c14acaa5 | |||
| 2701dfbf85 | |||
| 6aa3421f0c | |||
| b6cc945adb | |||
| a22d808b7b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1 @@
|
|||||||
pos_database.db
|
.env
|
||||||
ScannerGO/ScannerGO-*
|
|
||||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Build SekiPOS (F5)",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build-sekipos"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"python-envs.defaultEnvManager": "ms-python.python:system"
|
||||||
|
}
|
||||||
15
.vscode/tasks.json
vendored
Normal file
15
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "build-sekipos",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker build -t sekipos:latest .",
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "new"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
Dockerfile
18
Dockerfile
@@ -2,21 +2,25 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY app.py .
|
COPY . .
|
||||||
COPY templates/ ./templates/
|
|
||||||
COPY static/ ./static/
|
|
||||||
|
|
||||||
# Create the folder structure for the volume mounts
|
# Create necessary directories
|
||||||
RUN mkdir -p /app/static/cache
|
RUN mkdir -p /app/db /app/static/cache
|
||||||
|
|
||||||
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Run with unbuffered output so you can actually see the logs in Portainer
|
# Run with unbuffered output
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
CMD ["python", "app.py"]
|
CMD ["python", "app.py"]
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import barcode
|
|
||||||
from barcode.writer import ImageWriter
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
|
|
||||||
# Items to generate
|
|
||||||
ITEMS = [
|
|
||||||
{"name": "Plátano", "icon": "🍌"},
|
|
||||||
{"name": "Manzana", "icon": "🍎"},
|
|
||||||
{"name": "Tomate", "icon": "🍅"},
|
|
||||||
{"name": "Lechuga", "icon": "🥬"},
|
|
||||||
{"name": "Cebolla", "icon": "🧅"},
|
|
||||||
{"name": "Pan Batido", "icon": "🥖"}
|
|
||||||
]
|
|
||||||
|
|
||||||
os.makedirs('keychain_cards', exist_ok=True)
|
|
||||||
|
|
||||||
def generate_card(item):
|
|
||||||
name = item['name']
|
|
||||||
# Generate a private EAN-13 starting with 99
|
|
||||||
# We need 12 digits (the 13th is a checksum added by the library)
|
|
||||||
random_digits = ''.join([str(random.randint(0, 9)) for _ in range(10)])
|
|
||||||
code_str = f"99{random_digits}"
|
|
||||||
|
|
||||||
# Generate Barcode Image
|
|
||||||
EAN = barcode.get_barcode_class('ean13')
|
|
||||||
ean = EAN(code_str, writer=ImageWriter())
|
|
||||||
|
|
||||||
# Customizing the output image size
|
|
||||||
options = {
|
|
||||||
'module_height': 15.0,
|
|
||||||
'font_size': 10,
|
|
||||||
'text_distance': 3.0,
|
|
||||||
'write_text': True
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create the card canvas (300x450 pixels ~ 2.5x3.5 inches)
|
|
||||||
card = Image.new('RGB', (300, 400), color='white')
|
|
||||||
draw = ImageDraw.Draw(card)
|
|
||||||
|
|
||||||
# Draw a border for cutting
|
|
||||||
draw.rectangle([0, 0, 299, 399], outline="black", width=2)
|
|
||||||
|
|
||||||
# Try to add the Emoji/Text (Requires a font that supports emojis, otherwise just text)
|
|
||||||
try:
|
|
||||||
# If on Linux, try to find a ttf
|
|
||||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
|
|
||||||
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 25)
|
|
||||||
except:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
title_font = ImageFont.load_default()
|
|
||||||
|
|
||||||
# Draw Name and Emoji
|
|
||||||
draw.text((150, 60), item['icon'], fill="black", font=font, anchor="mm")
|
|
||||||
draw.text((150, 120), name, fill="black", font=title_font, anchor="mm")
|
|
||||||
|
|
||||||
# Save barcode to temp file
|
|
||||||
barcode_img_path = f"keychain_cards/{code_str}_tmp"
|
|
||||||
ean.save(barcode_img_path, options=options)
|
|
||||||
|
|
||||||
# Paste barcode onto card
|
|
||||||
b_img = Image.open(f"{barcode_img_path}.png")
|
|
||||||
b_img = b_img.resize((260, 180)) # Resize to fit card
|
|
||||||
card.paste(b_img, (20, 180))
|
|
||||||
|
|
||||||
# Cleanup and save final
|
|
||||||
os.remove(f"{barcode_img_path}.png")
|
|
||||||
final_path = f"keychain_cards/{name.replace(' ', '_')}.png"
|
|
||||||
card.save(final_path)
|
|
||||||
print(f"Generated {name}: {ean.get_fullcode()}")
|
|
||||||
|
|
||||||
for item in ITEMS:
|
|
||||||
generate_card(item)
|
|
||||||
|
|
||||||
print("\nAll cards generated in 'keychain_cards/' folder.")
|
|
||||||
70
README.md
70
README.md
@@ -1,4 +1,4 @@
|
|||||||
# SekiPOS v1.0 🍫🥤
|
# 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.
|
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.
|
||||||
|
|
||||||
@@ -8,6 +8,45 @@ A reactive POS inventory system for software engineers with a snack addiction. F
|
|||||||
- **Local Cache:** Saves images locally to `static/cache` to avoid IP bans.
|
- **Local Cache:** Saves images locally to `static/cache` to avoid IP bans.
|
||||||
- **CLP Ready:** Chilean Peso formatting ($1.234) for local commerce.
|
- **CLP Ready:** Chilean Peso formatting ($1.234) for local commerce.
|
||||||
- **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!
|
||||||
|
|
||||||
|
## 📦 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)
|
||||||
|
|
||||||
@@ -15,15 +54,16 @@ Build and run the central inventory server:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the image
|
# Build the image
|
||||||
docker build -t sekipos .
|
docker build -t sekipos:latest .
|
||||||
|
|
||||||
# Run the container (Map port 5000 and persist the database/cache)
|
# Run the container (Map port 5000 and persist the database/cache)
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 5000:5000 \
|
-p 5000:5000 \
|
||||||
-v $(pwd)/pos_database.db:/app/pos_database.db \
|
-v $(pwd)/sekipos/db:/app/db \
|
||||||
-v $(pwd)/static/cache:/app/static/cache \
|
-v $(pwd)/sekipos/static/cache:/app/static/cache \
|
||||||
--name sekipos-server \
|
--name sekipos-server \
|
||||||
sekipos
|
--restart unless-stopped \
|
||||||
|
sekipos:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use this stack:
|
Or use this stack:
|
||||||
@@ -33,11 +73,14 @@ 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
|
||||||
container_name: sekipos-server
|
container_name: sekipos-server
|
||||||
image: sekipos
|
image: sekipos:latest
|
||||||
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔌 Hardware Scanner Bridge (`ScannerGO`)
|
## 🔌 Hardware Scanner Bridge (`ScannerGO`)
|
||||||
@@ -57,6 +100,11 @@ chmod +x ScannerGO-linux
|
|||||||
|
|
||||||
*Note: Ensure the `-url` points to your Docker container's IP address.*
|
*Note: Ensure the `-url` points to your Docker container's IP address.*
|
||||||
|
|
||||||
|
All this program does its send the COM data from the scanner gun to:
|
||||||
|
```
|
||||||
|
https://scanner.sekidesu.xyz/scan?content=BAR-CODE
|
||||||
|
```
|
||||||
|
|
||||||
## 📦 Local Installation (Development)
|
## 📦 Local Installation (Development)
|
||||||
|
|
||||||
If you're too afraid of Docker:
|
If you're too afraid of Docker:
|
||||||
@@ -72,4 +120,12 @@ python app.py
|
|||||||
## 📁 Structure
|
## 📁 Structure
|
||||||
- `app.py`: The inventory/web server.
|
- `app.py`: The inventory/web server.
|
||||||
- `static/cache/`: Local repository for product images.
|
- `static/cache/`: Local repository for product images.
|
||||||
- `pos_database.db`: SQLite storage.
|
- `db/pos_database.db`: SQLite storage.
|
||||||
|
|
||||||
|
## 📋 TODOs?
|
||||||
|
- Some form of user registration(?)
|
||||||
|
- Major refactoring of the codebase
|
||||||
|
|
||||||
|
## 🥼 Food Datasets
|
||||||
|
- https://www.ifpsglobal.com/plu-codes-search
|
||||||
|
- https://world.openfoodfacts.org
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tarm/serial"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
portName := flag.String("port", "/dev/ttyACM0", "Serial port name")
|
|
||||||
endpoint := flag.String("url", "https://scanner.sekidesu.xyz/scan", "Target URL endpoint")
|
|
||||||
baudRate := flag.Int("baud", 115200, "Baud rate")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
config := &serial.Config{
|
|
||||||
Name: *portName,
|
|
||||||
Baud: *baudRate,
|
|
||||||
ReadTimeout: time.Second * 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
port, err := serial.OpenPort(config)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error opening port %s: %v\n", *portName, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer port.Close()
|
|
||||||
|
|
||||||
fmt.Printf("Listening on %s (Baud: %d)...\n", *portName, *baudRate)
|
|
||||||
fmt.Printf("Sending data to: %s\n", *endpoint)
|
|
||||||
|
|
||||||
buf := make([]byte, 128)
|
|
||||||
for {
|
|
||||||
n, err := port.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
if err != io.EOF {
|
|
||||||
fmt.Printf("Read error: %v\n", err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if n > 0 {
|
|
||||||
content := strings.TrimSpace(string(buf[:n]))
|
|
||||||
if content != "" {
|
|
||||||
sendToEndpoint(*endpoint, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendToEndpoint(baseURL, content string) {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
fullURL := fmt.Sprintf("%s?content=%s", baseURL, url.QueryEscape(content))
|
|
||||||
|
|
||||||
resp, err := client.Get(fullURL)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Network Error: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
fmt.Printf("Data: [%s] | Status: %s\n", content, resp.Status)
|
|
||||||
}
|
|
||||||
201
app.py
201
app.py
@@ -1,154 +1,79 @@
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sys
|
||||||
import requests
|
import time
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, send_from_directory
|
import threading
|
||||||
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
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
from flask import Flask, redirect, url_for, send_file, jsonify
|
||||||
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
|
from flask_login import login_required, current_user
|
||||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
from werkzeug.security import generate_password_hash
|
||||||
|
import webview
|
||||||
|
from flask_socketio import SocketIO
|
||||||
|
|
||||||
# Auth Setup
|
from core.utils import get_bundled_path, get_persistent_path
|
||||||
login_manager = LoginManager(app)
|
from core.db import init_db as init_db_core, get_db_connection
|
||||||
login_manager.login_view = 'login'
|
from core.events import socketio
|
||||||
|
|
||||||
DB_FILE = 'db/pos_database.db'
|
from blueprints.auth import auth_bp, init_login_manager
|
||||||
CACHE_DIR = 'static/cache'
|
from blueprints.finance import finance_bp
|
||||||
|
from blueprints.inventory import inventory_bp
|
||||||
|
from blueprints.pos import pos_bp
|
||||||
|
from blueprints.sales import sales_bp
|
||||||
|
|
||||||
|
# --- PYINSTALLER WINDOWED MODE FIX ---
|
||||||
|
if 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
|
||||||
|
|
||||||
|
# --- 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'
|
||||||
|
|
||||||
|
# --- 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)
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
app.config['CACHE_DIR'] = CACHE_DIR
|
||||||
|
|
||||||
# --- MODELS ---
|
# --- BLUEPRINT REGISTRATION ---
|
||||||
class User(UserMixin):
|
app.register_blueprint(auth_bp)
|
||||||
def __init__(self, id, username):
|
app.register_blueprint(finance_bp)
|
||||||
self.id = id
|
app.register_blueprint(inventory_bp)
|
||||||
self.username = username
|
app.register_blueprint(pos_bp)
|
||||||
|
app.register_blueprint(sales_bp)
|
||||||
|
|
||||||
# --- DATABASE LOGIC ---
|
init_login_manager(app)
|
||||||
def init_db():
|
socketio.init_app(app, cors_allowed_origins="*", async_mode='threading')
|
||||||
with sqlite3.connect(DB_FILE) 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)''')
|
|
||||||
|
|
||||||
# Default user: admin / Pass: choripan1234
|
|
||||||
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
|
# --- DATABASE INITIALIZATION ---
|
||||||
def load_user(user_id):
|
init_db_core(DB_FILE)
|
||||||
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
|
|
||||||
|
|
||||||
# --- HELPERS ---
|
|
||||||
def download_image(url, barcode):
|
|
||||||
if not url or not url.startswith('http'): return url
|
|
||||||
local_filename = f"{barcode}.jpg"
|
|
||||||
local_path = os.path.join(CACHE_DIR, local_filename)
|
|
||||||
if os.path.exists(local_path): return f"/static/cache/{local_filename}"
|
|
||||||
try:
|
|
||||||
headers = {'User-Agent': 'SekiPOS/1.0'}
|
|
||||||
r = requests.get(url, headers=headers, stream=True, timeout=5)
|
|
||||||
if r.status_code == 200:
|
|
||||||
with open(local_path, 'wb') as f:
|
|
||||||
for chunk in r.iter_content(1024): f.write(chunk)
|
|
||||||
return f"/static/cache/{local_filename}"
|
|
||||||
except: pass
|
|
||||||
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', '')
|
|
||||||
local_img = download_image(img_url, barcode)
|
|
||||||
return {"name": name, "image": local_img}
|
|
||||||
except: pass
|
|
||||||
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('index'))
|
|
||||||
flash('Invalid credentials.')
|
|
||||||
return render_template('login.html')
|
|
||||||
|
|
||||||
@app.route('/logout')
|
|
||||||
@login_required
|
|
||||||
def logout():
|
|
||||||
logout_user(); return redirect(url_for('login'))
|
|
||||||
|
|
||||||
|
# --- ROOT ROUTE ---
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
return redirect(url_for('inventory.inventory'))
|
||||||
products = conn.execute('SELECT * FROM products').fetchall()
|
|
||||||
return render_template('index.html', products=products, user=current_user)
|
|
||||||
|
|
||||||
@app.route('/upsert', methods=['POST'])
|
# --- RUN FUNCTION ---
|
||||||
@login_required
|
def start_server():
|
||||||
def upsert():
|
socketio.run(app, host='127.0.0.1', port=5000, log_output=False, allow_unsafe_werkzeug=True)
|
||||||
d = request.form
|
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
|
||||||
conn.execute('''INSERT INTO products (barcode, name, price, image_url) VALUES (?,?,?,?)
|
|
||||||
ON CONFLICT(barcode) DO UPDATE SET name=excluded.name,
|
|
||||||
price=excluded.price, image_url=excluded.image_url''',
|
|
||||||
(d['barcode'], d['name'], d['price'], d['image_url']))
|
|
||||||
conn.commit()
|
|
||||||
return redirect(url_for('index'))
|
|
||||||
|
|
||||||
@app.route('/delete/<barcode>', methods=['POST'])
|
def run_standalone():
|
||||||
@login_required
|
t = threading.Thread(target=start_server)
|
||||||
def delete(barcode):
|
t.daemon = True
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
t.start()
|
||||||
conn.execute('DELETE FROM products WHERE barcode = ?', (barcode,))
|
time.sleep(2)
|
||||||
conn.commit()
|
webview.create_window('SekiPOS', 'http://127.0.0.1:5000', width=1366, height=768, resizable=True, fullscreen=False, min_size=(800, 600), maximized=True)
|
||||||
# Clean up cache
|
webview.start(private_mode=False)
|
||||||
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('index'))
|
|
||||||
|
|
||||||
@app.route('/scan', methods=['GET'])
|
|
||||||
def scan():
|
|
||||||
barcode = request.args.get('content', '').replace('{content}', '')
|
|
||||||
if not barcode: return jsonify({"err": "empty"}), 400
|
|
||||||
|
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
|
||||||
p = conn.execute('SELECT * FROM products WHERE barcode = ?', (barcode,)).fetchone()
|
|
||||||
|
|
||||||
if p:
|
|
||||||
socketio.emit('new_scan', {"barcode": p[0], "name": p[1], "price": int(p[2]), "image": p[3]})
|
|
||||||
return jsonify({"status": "ok"})
|
|
||||||
else:
|
|
||||||
ext = fetch_from_openfoodfacts(barcode)
|
|
||||||
if ext:
|
|
||||||
socketio.emit('scan_error', {"barcode": barcode, "name": ext['name'], "image": ext['image']})
|
|
||||||
else:
|
|
||||||
socketio.emit('scan_error', {"barcode": barcode})
|
|
||||||
return jsonify({"status": "not_found"}), 404
|
|
||||||
|
|
||||||
@app.route('/static/cache/<path:filename>')
|
|
||||||
def serve_cache(filename):
|
|
||||||
return send_from_directory(CACHE_DIR, filename)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_db()
|
#run_standalone() # Uncomment for desktop app
|
||||||
socketio.run(app, host='0.0.0.0', port=5000, debug=True)
|
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
|
||||||
2
extensions/go/DataToolsGO/.gitignore
vendored
Normal file
2
extensions/go/DataToolsGO/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
config.json
|
||||||
|
imageTools-*
|
||||||
42
extensions/go/DataToolsGO/build.sh
Executable file
42
extensions/go/DataToolsGO/build.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Define binary names
|
||||||
|
LINUX_BIN="imageTools-linux"
|
||||||
|
LINUX_ARM_BIN="imageTools-linuxARMv7"
|
||||||
|
WINDOWS_BIN="imageTools-windows.exe"
|
||||||
|
|
||||||
|
echo "Starting build process..."
|
||||||
|
|
||||||
|
# Build for Linux (64-bit)
|
||||||
|
echo "Building for Linux..."
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o "$LINUX_BIN" main.go
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Successfully built: $LINUX_BIN"
|
||||||
|
else
|
||||||
|
echo "Failed to build Linux binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build for Windows (64-bit)
|
||||||
|
echo "Building for Windows..."
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o "$WINDOWS_BIN" main.go
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Successfully built: $WINDOWS_BIN"
|
||||||
|
else
|
||||||
|
echo "Failed to build Windows binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build for Linux ARM (ARMv7)
|
||||||
|
echo "Building for Linux ARMv7..."
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 go build -o "$LINUX_ARM_BIN" main.go
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Successfully built: $LINUX_ARM_BIN"
|
||||||
|
else
|
||||||
|
echo "Failed to build Linux ARMv7 binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Build complete."
|
||||||
5
extensions/go/DataToolsGO/go.mod
Normal file
5
extensions/go/DataToolsGO/go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module dataTools
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||||
2
extensions/go/DataToolsGO/go.sum
Normal file
2
extensions/go/DataToolsGO/go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
84
extensions/go/DataToolsGO/main.go
Normal file
84
extensions/go/DataToolsGO/main.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nfnt/resize"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Command line arguments
|
||||||
|
dirPath := flag.String("dir", "./", "Directory containing images")
|
||||||
|
maxWidth := flag.Uint("width", 1000, "Maximum width for resizing")
|
||||||
|
quality := flag.Int("quality", 75, "JPEG quality (1-100)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
files, err := os.ReadDir(*dirPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading directory: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Processing images in %s (Max Width: %d, Quality: %d)\n", *dirPath, *maxWidth, *quality)
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(file.Name()))
|
||||||
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(*dirPath, file.Name())
|
||||||
|
processImage(filePath, *maxWidth, *quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Done. Your storage can finally breathe again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func processImage(path string, maxWidth uint, quality int) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to open %s: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
img, _, err := image.Decode(file)
|
||||||
|
file.Close()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to decode %s: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only resize if original is wider than maxWidth
|
||||||
|
bounds := img.Bounds()
|
||||||
|
var finalImg image.Image
|
||||||
|
if uint(bounds.Dx()) > maxWidth {
|
||||||
|
finalImg = resize.Resize(maxWidth, 0, img, resize.Lanczos3)
|
||||||
|
fmt.Printf("Resized and compressed: %s\n", filepath.Base(path))
|
||||||
|
} else {
|
||||||
|
finalImg = img
|
||||||
|
fmt.Printf("Compressed (no resize needed): %s\n", filepath.Base(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite the original file
|
||||||
|
out, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to create output file %s: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
err = jpeg.Encode(out, finalImg, &jpeg.Options{Quality: quality})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to encode %s: %v\n", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
extensions/go/MPStudyGO/go.mod
Normal file
8
extensions/go/MPStudyGO/go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module MPPOS
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
)
|
||||||
4
extensions/go/MPStudyGO/go.sum
Normal file
4
extensions/go/MPStudyGO/go.sum
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
143
extensions/go/MPStudyGO/main.go
Normal file
143
extensions/go/MPStudyGO/main.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request structures based on MP documentation
|
||||||
|
type PrintRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ExternalReference string `json:"external_reference"`
|
||||||
|
Config PrintConfig `json:"config"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrintConfig struct {
|
||||||
|
Point PointSettings `json:"point"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointSettings struct {
|
||||||
|
TerminalID string `json:"terminal_id"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
err := godotenv.Load("../.env")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error loading .env file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example receipt using supported tags: {b}, {center}, {br}, {s}
|
||||||
|
receiptContent := "{center}{b}SEKIPOS VENTA{/b}{br}" +
|
||||||
|
"--------------------------------{br}" +
|
||||||
|
"{left}Producto: Choripan Premium{br}" +
|
||||||
|
"{left}Total: $5.500{br}" +
|
||||||
|
"--------------------------------{br}" +
|
||||||
|
"{center}{s}Gracias por su compra{/s}{br}"
|
||||||
|
|
||||||
|
resp, err := SendPrintAction(receiptContent)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Response: %s\n", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendPrintAction(content string) (string, error) {
|
||||||
|
apiURL := "https://api.mercadopago.com/terminals/v1/actions"
|
||||||
|
accessToken := os.Getenv("MP_ACCESS_TOKEN")
|
||||||
|
terminalID := os.Getenv("MP_TERMINAL_ID")
|
||||||
|
|
||||||
|
payload := PrintRequest{
|
||||||
|
Type: "print", // Required
|
||||||
|
ExternalReference: fmt.Sprintf("ref_%d", time.Now().Unix()),
|
||||||
|
Config: PrintConfig{
|
||||||
|
Point: PointSettings{
|
||||||
|
TerminalID: terminalID,
|
||||||
|
Subtype: "custom", // For text with tags
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Content: content, // Must be 100-4096 chars
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
// Mandatory Unique UUID V4
|
||||||
|
req.Header.Set("X-Idempotency-Key", uuid.New().String())
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("MP Error %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PartialRefund(orderID string, paymentID string, amount string) (string, error) {
|
||||||
|
// Endpoint para reembolsos según la referencia de API
|
||||||
|
apiURL := fmt.Sprintf("https://api.mercadopago.com/v1/orders/%s/refund", orderID)
|
||||||
|
token := os.Getenv("MP_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"transactions": []map[string]string{
|
||||||
|
{
|
||||||
|
"id": paymentID,
|
||||||
|
"amount": amount, // Debe ser un string sin decimales
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Idempotency-Key", uuid.New().String())
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
resBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return string(resBody), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOrderStatus(orderID string) (string, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://api.mercadopago.com/v1/orders/%s", orderID)
|
||||||
|
token := os.Getenv("MP_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", apiURL, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
resBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return string(resBody), nil
|
||||||
|
}
|
||||||
2
extensions/go/ScannerCOM/.gitignore
vendored
Normal file
2
extensions/go/ScannerCOM/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
config.json
|
||||||
|
COMScannerGO-*
|
||||||
42
extensions/go/ScannerCOM/build.sh
Executable file
42
extensions/go/ScannerCOM/build.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Define binary names
|
||||||
|
LINUX_BIN="COMScannerGO-linux"
|
||||||
|
LINUX_ARM_BIN="COMScannerGO-linuxARMv7"
|
||||||
|
WINDOWS_BIN="COMScannerGO-windows.exe"
|
||||||
|
|
||||||
|
echo "Starting build process..."
|
||||||
|
|
||||||
|
# Build for Linux (64-bit)
|
||||||
|
echo "Building for Linux..."
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o "$LINUX_BIN" main.go
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Successfully built: $LINUX_BIN"
|
||||||
|
else
|
||||||
|
echo "Failed to build Linux binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build for Windows (64-bit)
|
||||||
|
echo "Building for Windows..."
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o "$WINDOWS_BIN" main.go
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Successfully built: $WINDOWS_BIN"
|
||||||
|
else
|
||||||
|
echo "Failed to build Windows binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build for Linux ARM (ARMv7)
|
||||||
|
echo "Building for Linux ARMv7..."
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 go build -o "$LINUX_ARM_BIN" main.go
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Successfully built: $LINUX_ARM_BIN"
|
||||||
|
else
|
||||||
|
echo "Failed to build Linux ARMv7 binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Build complete."
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module ScannerGO
|
module ScannerGO
|
||||||
|
|
||||||
go 1.25.7
|
go 1.24.0
|
||||||
|
|
||||||
require github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
require github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||||
|
|
||||||
175
extensions/go/ScannerCOM/main.go
Normal file
175
extensions/go/ScannerCOM/main.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tarm/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string `json:"port"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
BaudRate int `json:"baud"`
|
||||||
|
Delimiter string `json:"delimiter"`
|
||||||
|
FlowControl string `json:"flow_control"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultConfig = Config{
|
||||||
|
Port: "/dev/ttyACM0",
|
||||||
|
URL: "https://scanner.sekidesu.xyz/scan",
|
||||||
|
BaudRate: 115200,
|
||||||
|
Delimiter: "\n",
|
||||||
|
FlowControl: "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = "config.json"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
portName := flag.String("port", cfg.Port, "Serial port name")
|
||||||
|
endpoint := flag.String("url", cfg.URL, "Target URL endpoint")
|
||||||
|
baudRate := flag.Int("baud", cfg.BaudRate, "Baud rate")
|
||||||
|
delim := flag.String("delim", cfg.Delimiter, "Line delimiter")
|
||||||
|
flow := flag.String("flow", cfg.FlowControl, "Flow control: none, hardware, software")
|
||||||
|
save := flag.Bool("save", false, "Save current parameters to config.json")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg.Port = *portName
|
||||||
|
cfg.URL = *endpoint
|
||||||
|
cfg.BaudRate = *baudRate
|
||||||
|
cfg.Delimiter = *delim
|
||||||
|
cfg.FlowControl = *flow
|
||||||
|
|
||||||
|
if *save {
|
||||||
|
saveConfig(cfg)
|
||||||
|
fmt.Println("Settings saved to", configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialConfig := &serial.Config{
|
||||||
|
Name: cfg.Port,
|
||||||
|
Baud: cfg.BaudRate,
|
||||||
|
ReadTimeout: time.Millisecond * 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarm/serial uses boolean flags for flow control if available in the version used
|
||||||
|
// If your version doesn't support these fields, you may need to update the package
|
||||||
|
// or manage the lines manually via the file descriptor.
|
||||||
|
/* Note: tarm/serial usually requires specific fork or version
|
||||||
|
for full RTS/CTS hardware flow control support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
port, err := serial.OpenPort(serialConfig)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error opening port %s: %v\n", cfg.Port, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer port.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Listening on %s (Baud: %d, Flow: %s)...\n", cfg.Port, cfg.BaudRate, cfg.FlowControl)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(port)
|
||||||
|
|
||||||
|
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
|
if atEOF && len(data) == 0 {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
if i := bytes.Index(data, []byte(cfg.Delimiter)); i >= 0 {
|
||||||
|
return i + len(cfg.Delimiter), data[0:i], nil
|
||||||
|
}
|
||||||
|
if atEOF {
|
||||||
|
return len(data), data, nil
|
||||||
|
}
|
||||||
|
return 0, nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
rawContent := scanner.Text()
|
||||||
|
content := strings.TrimFunc(rawContent, func(r rune) bool {
|
||||||
|
return r < 32 || r > 126
|
||||||
|
})
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
sendToEndpoint(cfg.URL, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
fmt.Printf("Scanner error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() Config {
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
saveConfig(defaultConfig)
|
||||||
|
return defaultConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return defaultConfig
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
if err := decoder.Decode(&cfg); err != nil {
|
||||||
|
return defaultConfig
|
||||||
|
}
|
||||||
|
if cfg.Delimiter == "" {
|
||||||
|
cfg.Delimiter = "\n"
|
||||||
|
}
|
||||||
|
if cfg.FlowControl == "" {
|
||||||
|
cfg.FlowControl = "none"
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfig(cfg Config) {
|
||||||
|
file, err := os.Create(configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to create/save config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(file)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
encoder.Encode(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendToEndpoint(baseURL, content string) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
fullURL := fmt.Sprintf("%s?content=%s", baseURL, url.QueryEscape(content))
|
||||||
|
resp, err := client.Get(fullURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Network Error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading response: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Data: [%s] | Status: %s\n", content, resp.Status)
|
||||||
|
if len(body) > 0 {
|
||||||
|
fmt.Printf("Response: %s\n", string(body))
|
||||||
|
}
|
||||||
|
fmt.Println(strings.Repeat("-", 30))
|
||||||
|
}
|
||||||
2
extensions/go/ScannerHID/.gitignore
vendored
Normal file
2
extensions/go/ScannerHID/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
config.json
|
||||||
|
HIDScannerGO-*
|
||||||
18
extensions/go/ScannerHID/build.sh
Executable file
18
extensions/go/ScannerHID/build.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
LINUX_BIN="HIDScannerGO-linux"
|
||||||
|
WINDOWS_BIN="HIDScannerGO-windows.exe"
|
||||||
|
|
||||||
|
echo "Starting build process..."
|
||||||
|
|
||||||
|
# 1. Build for Linux (Host)
|
||||||
|
echo "Building for Linux (64-bit)..."
|
||||||
|
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o "$LINUX_BIN" .
|
||||||
|
|
||||||
|
# 2. Build for Windows (Needs MinGW)
|
||||||
|
echo "Building for Windows (64-bit)..."
|
||||||
|
# We must point to the mingw gcc and enable CGO
|
||||||
|
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
|
||||||
|
go build -ldflags="-H=windowsgui" -o "$WINDOWS_BIN" .
|
||||||
|
|
||||||
|
echo "Build complete."
|
||||||
46
extensions/go/ScannerHID/go.mod
Normal file
46
extensions/go/ScannerHID/go.mod
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
module ScannerGO
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.7.3
|
||||||
|
gioui.org v0.9.0
|
||||||
|
github.com/google/gousb v1.1.3
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/systray v1.12.0 // indirect
|
||||||
|
gioui.org/shader v1.0.8 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fredbi/uri v1.1.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.3.0 // indirect
|
||||||
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
|
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||||
|
github.com/go-text/render v0.2.0 // indirect
|
||||||
|
github.com/go-text/typesetting v0.3.3 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rymdport/portal v0.4.2 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
|
golang.org/x/image v0.26.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
93
extensions/go/ScannerHID/go.sum
Normal file
93
extensions/go/ScannerHID/go.sum
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
|
fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
|
||||||
|
fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw=
|
||||||
|
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||||
|
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
||||||
|
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||||
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||||
|
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||||
|
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||||
|
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||||
|
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
|
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
|
||||||
|
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||||
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
|
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||||
|
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||||
|
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||||
|
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
|
||||||
|
github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/gousb v1.1.3 h1:xt6M5TDsGSZ+rlomz5Si5Hmd/Fvbmo2YCJHN+yGaK4o=
|
||||||
|
github.com/google/gousb v1.1.3/go.mod h1:GGWUkK0gAXDzxhwrzetW592aOmkkqSGcj5KLEgmCVUg=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
|
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||||
|
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||||
|
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||||
|
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||||
|
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||||
|
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
BIN
extensions/go/ScannerHID/icon.ico
Normal file
BIN
extensions/go/ScannerHID/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
281
extensions/go/ScannerHID/main.go
Normal file
281
extensions/go/ScannerHID/main.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"github.com/google/gousb"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed icon.ico
|
||||||
|
var iconData []byte
|
||||||
|
|
||||||
|
type HexUint16 uint16
|
||||||
|
|
||||||
|
func (h HexUint16) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(fmt.Sprintf("0x%04X", h))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HexUint16) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s = strings.TrimPrefix(s, "0x")
|
||||||
|
val, err := strconv.ParseUint(s, 16, 16)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*h = HexUint16(val)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
TargetURL string `json:"target_url"`
|
||||||
|
VendorID HexUint16 `json:"vendor_id"`
|
||||||
|
ProductID HexUint16 `json:"product_id"`
|
||||||
|
FallbackVID HexUint16 `json:"fallback_vendor_id"`
|
||||||
|
FallbackPID HexUint16 `json:"fallback_product_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var hidMap = map[byte]string{
|
||||||
|
4: "a", 5: "b", 6: "c", 7: "d", 8: "e", 9: "f", 10: "g", 11: "h", 12: "i",
|
||||||
|
13: "j", 14: "k", 15: "l", 16: "m", 17: "n", 18: "o", 19: "p", 20: "q",
|
||||||
|
21: "r", 22: "s", 23: "t", 24: "u", 25: "v", 26: "w", 27: "x", 28: "y", 29: "z",
|
||||||
|
30: "1", 31: "2", 32: "3", 33: "4", 34: "5", 35: "6", 36: "7", 37: "8", 38: "9", 39: "0",
|
||||||
|
44: " ", 45: "-", 46: "=", 55: ".", 56: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeApp struct {
|
||||||
|
urlEntry *widget.Entry
|
||||||
|
status *canvas.Text
|
||||||
|
logList *widget.List
|
||||||
|
logs []string
|
||||||
|
window fyne.Window
|
||||||
|
config Config
|
||||||
|
isCLI bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BridgeApp) saveConfig() {
|
||||||
|
b.config.TargetURL = b.urlEntry.Text
|
||||||
|
data, _ := json.MarshalIndent(b.config, "", " ")
|
||||||
|
_ = os.WriteFile("config.json", data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() Config {
|
||||||
|
conf := Config{
|
||||||
|
TargetURL: "https://scanner.sekidesu.xyz/scan",
|
||||||
|
VendorID: 0xFFFF,
|
||||||
|
ProductID: 0x0035,
|
||||||
|
FallbackVID: 0x04B3,
|
||||||
|
FallbackPID: 0x3107,
|
||||||
|
}
|
||||||
|
file, err := os.ReadFile("config.json")
|
||||||
|
if err == nil {
|
||||||
|
json.Unmarshal(file, &conf)
|
||||||
|
} else {
|
||||||
|
data, _ := json.MarshalIndent(conf, "", " ")
|
||||||
|
os.WriteFile("config.json", data, 0644)
|
||||||
|
}
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cliMode := flag.Bool("cli", false, "Run in CLI mode without GUI")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
conf := loadConfig()
|
||||||
|
bridge := &BridgeApp{
|
||||||
|
config: conf,
|
||||||
|
isCLI: *cliMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if *cliMode {
|
||||||
|
fmt.Println("Running in CLI mode...")
|
||||||
|
bridge.usbListenLoop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a := app.New()
|
||||||
|
w := a.NewWindow("POS Hardware Bridge (Go)")
|
||||||
|
w.SetIcon(fyne.NewStaticResource("icon.ico", iconData))
|
||||||
|
|
||||||
|
bridge.window = w
|
||||||
|
bridge.urlEntry = widget.NewEntry()
|
||||||
|
bridge.urlEntry.SetText(conf.TargetURL)
|
||||||
|
bridge.status = canvas.NewText("Status: Booting...", color.Black)
|
||||||
|
bridge.status.TextSize = 14
|
||||||
|
|
||||||
|
bridge.logList = widget.NewList(
|
||||||
|
func() int { return len(bridge.logs) },
|
||||||
|
func() fyne.CanvasObject { return widget.NewLabel("template") },
|
||||||
|
func(i widget.ListItemID, o fyne.CanvasObject) {
|
||||||
|
o.(*widget.Label).SetText(bridge.logs[i])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
content := container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
widget.NewLabel("Target POS Endpoint:"),
|
||||||
|
bridge.urlEntry,
|
||||||
|
bridge.status,
|
||||||
|
widget.NewLabel("Activity Log:"),
|
||||||
|
),
|
||||||
|
nil, nil, nil,
|
||||||
|
bridge.logList,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.SetContent(content)
|
||||||
|
w.Resize(fyne.NewSize(500, 400))
|
||||||
|
|
||||||
|
w.SetOnClosed(func() {
|
||||||
|
if !bridge.isCLI {
|
||||||
|
bridge.config.TargetURL = bridge.urlEntry.Text
|
||||||
|
data, err := json.MarshalIndent(bridge.config, "", " ")
|
||||||
|
if err == nil {
|
||||||
|
_ = os.WriteFile("config.json", data, 0644)
|
||||||
|
fmt.Println("Configuration saved.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
go bridge.usbListenLoop()
|
||||||
|
w.ShowAndRun()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BridgeApp) addLog(msg string) {
|
||||||
|
ts := time.Now().Format("15:04:05")
|
||||||
|
formatted := fmt.Sprintf("[%s] %s", ts, msg)
|
||||||
|
|
||||||
|
if b.isCLI {
|
||||||
|
fmt.Println(formatted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fyne.DoAndWait(func() {
|
||||||
|
b.logs = append([]string{formatted}, b.logs...)
|
||||||
|
if len(b.logs) > 15 {
|
||||||
|
b.logs = b.logs[:15]
|
||||||
|
}
|
||||||
|
b.logList.Refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BridgeApp) updateStatus(msg string, col color.Color) {
|
||||||
|
if b.isCLI {
|
||||||
|
fmt.Printf("STATUS: %s\n", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fyne.DoAndWait(func() {
|
||||||
|
b.status.Text = msg
|
||||||
|
b.status.Color = col
|
||||||
|
b.status.Refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BridgeApp) sendToPos(barcode string) {
|
||||||
|
url := b.config.TargetURL
|
||||||
|
if !b.isCLI {
|
||||||
|
url = b.urlEntry.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
b.addLog(fmt.Sprintf("Captured: %s. Sending to %s", barcode, url))
|
||||||
|
client := http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get(url + "?content=" + barcode)
|
||||||
|
if err != nil {
|
||||||
|
b.addLog("HTTP Error: Backend unreachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b.addLog(fmt.Sprintf("Success: POS returned %d", resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BridgeApp) usbListenLoop() {
|
||||||
|
ctx := gousb.NewContext()
|
||||||
|
defer ctx.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
dev, err := ctx.OpenDeviceWithVIDPID(gousb.ID(b.config.VendorID), gousb.ID(b.config.ProductID))
|
||||||
|
|
||||||
|
if (err != nil || dev == nil) && (b.config.FallbackVID != 0) {
|
||||||
|
dev, err = ctx.OpenDeviceWithVIDPID(gousb.ID(b.config.FallbackVID), gousb.ID(b.config.FallbackPID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || dev == nil {
|
||||||
|
b.updateStatus("Scanner unplugged. Waiting...", color.NRGBA{200, 0, 0, 255})
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.updateStatus(fmt.Sprintf("Scanner Ready (0x%04X)", dev.Desc.Vendor), color.NRGBA{0, 180, 0, 255})
|
||||||
|
|
||||||
|
intf, done, err := dev.DefaultInterface()
|
||||||
|
if err != nil {
|
||||||
|
b.addLog("Error claiming interface")
|
||||||
|
dev.Close()
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var inEp *gousb.InEndpoint
|
||||||
|
for _, epDesc := range intf.Setting.Endpoints {
|
||||||
|
if epDesc.Direction == gousb.EndpointDirectionIn {
|
||||||
|
inEp, _ = intf.InEndpoint(epDesc.Number)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inEp == nil {
|
||||||
|
b.addLog("No IN endpoint found")
|
||||||
|
done()
|
||||||
|
dev.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBarcode := ""
|
||||||
|
buf := make([]byte, inEp.Desc.MaxPacketSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := inEp.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n < 3 || buf[2] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
modifier := buf[0]
|
||||||
|
keycode := buf[2]
|
||||||
|
isShift := (modifier == 2 || modifier == 32)
|
||||||
|
|
||||||
|
if keycode == 40 {
|
||||||
|
if currentBarcode != "" {
|
||||||
|
go b.sendToPos(currentBarcode)
|
||||||
|
currentBarcode = ""
|
||||||
|
}
|
||||||
|
} else if val, ok := hidMap[keycode]; ok {
|
||||||
|
if isShift && len(val) == 1 && val[0] >= 'a' && val[0] <= 'z' {
|
||||||
|
val = string(val[0] - 32)
|
||||||
|
}
|
||||||
|
currentBarcode += val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done()
|
||||||
|
dev.Close()
|
||||||
|
b.addLog("Hardware connection lost. Reconnecting...")
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
extensions/platformio/DISPLAY_SEGMENTS.xcf
Normal file
BIN
extensions/platformio/DISPLAY_SEGMENTS.xcf
Normal file
Binary file not shown.
5
extensions/platformio/HX711_read/.gitignore
vendored
Normal file
5
extensions/platformio/HX711_read/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
10
extensions/platformio/HX711_read/.vscode/extensions.json
vendored
Normal file
10
extensions/platformio/HX711_read/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
37
extensions/platformio/HX711_read/include/README
Normal file
37
extensions/platformio/HX711_read/include/README
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
This directory is intended for project header files.
|
||||||
|
|
||||||
|
A header file is a file containing C declarations and macro definitions
|
||||||
|
to be shared between several project source files. You request the use of a
|
||||||
|
header file in your project source file (C, C++, etc) located in `src` folder
|
||||||
|
by including it, with the C preprocessing directive `#include'.
|
||||||
|
|
||||||
|
```src/main.c
|
||||||
|
|
||||||
|
#include "header.h"
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including a header file produces the same results as copying the header file
|
||||||
|
into each source file that needs it. Such copying would be time-consuming
|
||||||
|
and error-prone. With a header file, the related declarations appear
|
||||||
|
in only one place. If they need to be changed, they can be changed in one
|
||||||
|
place, and programs that include the header file will automatically use the
|
||||||
|
new version when next recompiled. The header file eliminates the labor of
|
||||||
|
finding and changing all the copies as well as the risk that a failure to
|
||||||
|
find one copy will result in inconsistencies within a program.
|
||||||
|
|
||||||
|
In C, the convention is to give header files names that end with `.h'.
|
||||||
|
|
||||||
|
Read more about using header files in official GCC documentation:
|
||||||
|
|
||||||
|
* Include Syntax
|
||||||
|
* Include Operation
|
||||||
|
* Once-Only Headers
|
||||||
|
* Computed Includes
|
||||||
|
|
||||||
|
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||||
46
extensions/platformio/HX711_read/lib/README
Normal file
46
extensions/platformio/HX711_read/lib/README
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
This directory is intended for project specific (private) libraries.
|
||||||
|
PlatformIO will compile them to static libraries and link into the executable file.
|
||||||
|
|
||||||
|
The source code of each library should be placed in a separate directory
|
||||||
|
("lib/your_library_name/[Code]").
|
||||||
|
|
||||||
|
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||||
|
|
||||||
|
|--lib
|
||||||
|
| |
|
||||||
|
| |--Bar
|
||||||
|
| | |--docs
|
||||||
|
| | |--examples
|
||||||
|
| | |--src
|
||||||
|
| | |- Bar.c
|
||||||
|
| | |- Bar.h
|
||||||
|
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||||
|
| |
|
||||||
|
| |--Foo
|
||||||
|
| | |- Foo.c
|
||||||
|
| | |- Foo.h
|
||||||
|
| |
|
||||||
|
| |- README --> THIS FILE
|
||||||
|
|
|
||||||
|
|- platformio.ini
|
||||||
|
|--src
|
||||||
|
|- main.c
|
||||||
|
|
||||||
|
Example contents of `src/main.c` using Foo and Bar:
|
||||||
|
```
|
||||||
|
#include <Foo.h>
|
||||||
|
#include <Bar.h>
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||||
|
libraries by scanning project source files.
|
||||||
|
|
||||||
|
More information about PlatformIO Library Dependency Finder
|
||||||
|
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||||
18
extensions/platformio/HX711_read/platformio.ini
Normal file
18
extensions/platformio/HX711_read/platformio.ini
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
; PlatformIO Project Configuration File
|
||||||
|
;
|
||||||
|
; Build options: build flags, source filter
|
||||||
|
; Upload options: custom upload port, speed and extra flags
|
||||||
|
; Library options: dependencies, extra library storages
|
||||||
|
; Advanced options: extra scripting
|
||||||
|
;
|
||||||
|
; Please visit documentation for the other options and examples
|
||||||
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
|
[env:waveshare_rp2040_zero]
|
||||||
|
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||||
|
board = waveshare_rp2040_zero
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
lib_deps =
|
||||||
|
https://github.com/Chris--A/Keypad
|
||||||
|
bogde/HX711@^0.7.5
|
||||||
84
extensions/platformio/HX711_read/src/main.cpp
Normal file
84
extensions/platformio/HX711_read/src/main.cpp
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "HX711.h"
|
||||||
|
|
||||||
|
const int LOADCELL_DOUT_PIN = 2;
|
||||||
|
const int LOADCELL_SCK_PIN = 3;
|
||||||
|
|
||||||
|
HX711 scale;
|
||||||
|
|
||||||
|
float smoothedWeight = 0.0;
|
||||||
|
float alpha = 0.3;
|
||||||
|
float calibration_factor = 111.17;
|
||||||
|
float known_weight = 796.0;
|
||||||
|
|
||||||
|
// Stability Lock Variables
|
||||||
|
float lastDisplayedWeight = 0.0;
|
||||||
|
const float STABILITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
void calibrateScale() {
|
||||||
|
Serial.println("--- Calibration Mode ---");
|
||||||
|
Serial.println("1. Clear scale, type 't' to tare.");
|
||||||
|
Serial.print("2. Place "); Serial.print(known_weight); Serial.println("g weight.");
|
||||||
|
Serial.println("3. Type 'c' to confirm weight.");
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (Serial.available()) {
|
||||||
|
char c = Serial.read();
|
||||||
|
if (c == 't') {
|
||||||
|
scale.tare();
|
||||||
|
Serial.println("Tared! Place weight and type 'c'...");
|
||||||
|
} else if (c == 'c') {
|
||||||
|
long reading = scale.get_value(15);
|
||||||
|
calibration_factor = (float)reading / known_weight;
|
||||||
|
scale.set_scale(calibration_factor);
|
||||||
|
Serial.print("New Factor: "); Serial.println(calibration_factor);
|
||||||
|
Serial.println("Calibration Done! Exiting mode.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
scale.begin(LOADCELL_DOUT_PIN, LOADCELL_SCK_PIN);
|
||||||
|
scale.set_gain(128);
|
||||||
|
scale.set_scale(calibration_factor);
|
||||||
|
scale.tare();
|
||||||
|
Serial.println("HX711 Ready (Stable Lock Mode)");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
if (scale.is_ready()) {
|
||||||
|
float currentReading = scale.get_units(1);
|
||||||
|
smoothedWeight = (alpha * currentReading) + (1.0 - alpha) * smoothedWeight;
|
||||||
|
|
||||||
|
// 1. Calculate the intended new display value
|
||||||
|
float targetWeight = round(smoothedWeight * 2.0) / 2.0;
|
||||||
|
if (abs(targetWeight) < 1.0) targetWeight = 0.0;
|
||||||
|
|
||||||
|
// 2. Stability Check
|
||||||
|
// Only update lastDisplayedWeight if the change exceeds the threshold
|
||||||
|
if (abs(targetWeight - lastDisplayedWeight) >= STABILITY_THRESHOLD) {
|
||||||
|
lastDisplayedWeight = targetWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.print("Weight: ");
|
||||||
|
Serial.print(lastDisplayedWeight, 1);
|
||||||
|
Serial.println(" g");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Serial.available()) {
|
||||||
|
char cmd = Serial.read();
|
||||||
|
if (cmd == 't') {
|
||||||
|
scale.tare();
|
||||||
|
smoothedWeight = 0;
|
||||||
|
lastDisplayedWeight = 0; // Force display to zero immediately
|
||||||
|
Serial.println(">> Tared");
|
||||||
|
} else if (cmd == 'k') {
|
||||||
|
calibrateScale();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(10);
|
||||||
|
}
|
||||||
11
extensions/platformio/HX711_read/test/README
Normal file
11
extensions/platformio/HX711_read/test/README
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
|
Unit Testing is a software testing method by which individual units of
|
||||||
|
source code, sets of one or more MCU program modules together with associated
|
||||||
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
|
in the development cycle.
|
||||||
|
|
||||||
|
More information about PlatformIO Unit Testing:
|
||||||
|
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||||
5
extensions/platformio/UART_SCALE_ALL/.gitignore
vendored
Normal file
5
extensions/platformio/UART_SCALE_ALL/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
10
extensions/platformio/UART_SCALE_ALL/.vscode/extensions.json
vendored
Normal file
10
extensions/platformio/UART_SCALE_ALL/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
37
extensions/platformio/UART_SCALE_ALL/include/README
Normal file
37
extensions/platformio/UART_SCALE_ALL/include/README
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
This directory is intended for project header files.
|
||||||
|
|
||||||
|
A header file is a file containing C declarations and macro definitions
|
||||||
|
to be shared between several project source files. You request the use of a
|
||||||
|
header file in your project source file (C, C++, etc) located in `src` folder
|
||||||
|
by including it, with the C preprocessing directive `#include'.
|
||||||
|
|
||||||
|
```src/main.c
|
||||||
|
|
||||||
|
#include "header.h"
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including a header file produces the same results as copying the header file
|
||||||
|
into each source file that needs it. Such copying would be time-consuming
|
||||||
|
and error-prone. With a header file, the related declarations appear
|
||||||
|
in only one place. If they need to be changed, they can be changed in one
|
||||||
|
place, and programs that include the header file will automatically use the
|
||||||
|
new version when next recompiled. The header file eliminates the labor of
|
||||||
|
finding and changing all the copies as well as the risk that a failure to
|
||||||
|
find one copy will result in inconsistencies within a program.
|
||||||
|
|
||||||
|
In C, the convention is to give header files names that end with `.h'.
|
||||||
|
|
||||||
|
Read more about using header files in official GCC documentation:
|
||||||
|
|
||||||
|
* Include Syntax
|
||||||
|
* Include Operation
|
||||||
|
* Once-Only Headers
|
||||||
|
* Computed Includes
|
||||||
|
|
||||||
|
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||||
46
extensions/platformio/UART_SCALE_ALL/lib/README
Normal file
46
extensions/platformio/UART_SCALE_ALL/lib/README
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
This directory is intended for project specific (private) libraries.
|
||||||
|
PlatformIO will compile them to static libraries and link into the executable file.
|
||||||
|
|
||||||
|
The source code of each library should be placed in a separate directory
|
||||||
|
("lib/your_library_name/[Code]").
|
||||||
|
|
||||||
|
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||||
|
|
||||||
|
|--lib
|
||||||
|
| |
|
||||||
|
| |--Bar
|
||||||
|
| | |--docs
|
||||||
|
| | |--examples
|
||||||
|
| | |--src
|
||||||
|
| | |- Bar.c
|
||||||
|
| | |- Bar.h
|
||||||
|
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||||
|
| |
|
||||||
|
| |--Foo
|
||||||
|
| | |- Foo.c
|
||||||
|
| | |- Foo.h
|
||||||
|
| |
|
||||||
|
| |- README --> THIS FILE
|
||||||
|
|
|
||||||
|
|- platformio.ini
|
||||||
|
|--src
|
||||||
|
|- main.c
|
||||||
|
|
||||||
|
Example contents of `src/main.c` using Foo and Bar:
|
||||||
|
```
|
||||||
|
#include <Foo.h>
|
||||||
|
#include <Bar.h>
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||||
|
libraries by scanning project source files.
|
||||||
|
|
||||||
|
More information about PlatformIO Library Dependency Finder
|
||||||
|
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||||
16
extensions/platformio/UART_SCALE_ALL/platformio.ini
Normal file
16
extensions/platformio/UART_SCALE_ALL/platformio.ini
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
; PlatformIO Project Configuration File
|
||||||
|
;
|
||||||
|
; Build options: build flags, source filter
|
||||||
|
; Upload options: custom upload port, speed and extra flags
|
||||||
|
; Library options: dependencies, extra library storages
|
||||||
|
; Advanced options: extra scripting
|
||||||
|
;
|
||||||
|
; Please visit documentation for the other options and examples
|
||||||
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
|
[env:waveshare_rp2040_zero]
|
||||||
|
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||||
|
board = waveshare_rp2040_zero
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
lib_deps = https://github.com/Chris--A/Keypad
|
||||||
50
extensions/platformio/UART_SCALE_ALL/src/TM1621_Config.h
Normal file
50
extensions/platformio/UART_SCALE_ALL/src/TM1621_Config.h
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#ifndef TM1621_CONFIG_H
|
||||||
|
#define TM1621_CONFIG_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
const uint8_t digitMap[] = {
|
||||||
|
0b11111100, // 0
|
||||||
|
0b00000110, // 1
|
||||||
|
0b01011011, // 2
|
||||||
|
0b01001111, // 3
|
||||||
|
0b01100110, // 4
|
||||||
|
0b01101101, // 5
|
||||||
|
0b01111101, // 6
|
||||||
|
0b00000111, // 7
|
||||||
|
0b01111111, // 8
|
||||||
|
0b01101111 // 9
|
||||||
|
};
|
||||||
|
|
||||||
|
const int arrows[] = {283,363,183,203}; //tare,?,?,Zero
|
||||||
|
const int battery[] = {63,83,103}; //LOW / MED / FULL
|
||||||
|
|
||||||
|
// Following your pattern: {A, B, C, D, E, F, G}
|
||||||
|
const int row1_d1[] = {300, 301, 302, 313, 312, 310, 311}; // Addresses 30 & 31
|
||||||
|
const int row1_d2[] = {280, 281, 282, 293, 292, 290, 291}; // Addresses 28 & 29
|
||||||
|
const int row1_d3[] = {260, 261, 262, 273, 272, 270, 271}; // Addresses 26 & 27
|
||||||
|
const int row1_d4[] = {240, 241, 242, 253, 252, 250, 251}; // Addresses 24 & 25
|
||||||
|
const int row1_d5[] = {220, 221, 222, 233, 232, 230, 231}; // Addresses 22 & 23
|
||||||
|
const int row1_decimal[] = {263,232,223}; //XX.X.X.X
|
||||||
|
|
||||||
|
const int row2_d1[] = {200, 201, 202, 213, 212, 210, 211}; // Addresses 20 & 21
|
||||||
|
const int row2_d2[] = {180, 181, 182, 193, 192, 190, 191}; // Addresses 18 & 19
|
||||||
|
const int row2_d3[] = {160, 161, 162, 173, 172, 170, 171}; // Addresses 16 & 17
|
||||||
|
const int row2_d4[] = {140, 141, 142, 153, 152, 150, 151}; // Addresses 14 & 15
|
||||||
|
const int row2_d5[] = {120, 121, 122, 133, 132, 130, 131}; // Addresses 12 & 13
|
||||||
|
const int row2_decimal[] = {163,143,123}; //XX.X.X.X
|
||||||
|
|
||||||
|
const int row3_d1[] = {100, 101, 102, 113, 112, 110, 111}; // Addresses 10 & 11
|
||||||
|
const int row3_d2[] = {80, 81, 82, 93, 92, 90, 91}; // Addresses 8 & 9
|
||||||
|
const int row3_d3[] = {60, 61, 62, 73, 72, 70, 71}; // Addresses 6 & 7
|
||||||
|
const int row3_d4[] = {40, 41, 42, 53, 52, 50, 51}; // Addresses 4 & 5
|
||||||
|
const int row3_d5[] = {20, 21, 22, 33, 32, 30, 31}; // Addresses 2 & 3
|
||||||
|
const int row3_d6[] = {0, 1, 2, 13, 12, 10, 11}; // Addresses 0 & 1
|
||||||
|
const int row3_decimal[] = {43,23,3}; //XX.X.X.X
|
||||||
|
|
||||||
|
const int* digitsRow1[] = {row1_d1, row1_d2, row1_d3, row1_d4, row1_d5};
|
||||||
|
const int* digitsRow2[] = {row2_d1, row2_d2, row2_d3, row2_d4, row2_d5};
|
||||||
|
const int* digitsRow3[] = {row3_d1, row3_d2, row3_d3, row3_d4, row3_d5, row3_d6};
|
||||||
|
|
||||||
|
const int* decimals[] = {row1_decimal, row2_decimal, row3_decimal};
|
||||||
|
#endif
|
||||||
270
extensions/platformio/UART_SCALE_ALL/src/main.cpp
Normal file
270
extensions/platformio/UART_SCALE_ALL/src/main.cpp
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "TM1621_Config.h"
|
||||||
|
#include <Keypad.h>
|
||||||
|
|
||||||
|
#define SCLK_SDI0819 1
|
||||||
|
#define DOUT_SDI0819 2
|
||||||
|
#define LCD_DATA 3
|
||||||
|
#define LCD_WR 4
|
||||||
|
#define LCD_CS 5
|
||||||
|
#define BUZZER_PIN 6
|
||||||
|
#define BACKLIGHT_PIN 7
|
||||||
|
|
||||||
|
int currentAddr = 0;
|
||||||
|
int currentBit = 0;
|
||||||
|
|
||||||
|
void writeBits(uint32_t data, uint8_t count) {
|
||||||
|
for (int8_t i = count - 1; i >= 0; i--) {
|
||||||
|
digitalWrite(LCD_WR, LOW);
|
||||||
|
digitalWrite(LCD_DATA, (data >> i) & 0x01);
|
||||||
|
digitalWrite(LCD_WR, HIGH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendCmd(uint8_t cmd) {
|
||||||
|
digitalWrite(LCD_CS, LOW);
|
||||||
|
writeBits(0x04, 3); // Binary 100 [cite: 432]
|
||||||
|
writeBits(cmd, 8); // Command [cite: 413, 534]
|
||||||
|
writeBits(0, 1); // X bit [cite: 416]
|
||||||
|
digitalWrite(LCD_CS, HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeAddr(uint8_t addr, uint8_t data) {
|
||||||
|
digitalWrite(LCD_CS, LOW);
|
||||||
|
uint16_t header = (0x05 << 6) | (addr & 0x3F); // Mode 101 [cite: 475]
|
||||||
|
writeBits(header, 9);
|
||||||
|
writeBits(data & 0x0F, 4); // 4 bits of data [cite: 475]
|
||||||
|
digitalWrite(LCD_CS, HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateDisplay() {
|
||||||
|
// Clear all segments first
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
writeAddr(i, 0x00);
|
||||||
|
}
|
||||||
|
// Light up only the current bit
|
||||||
|
writeAddr(currentAddr, (1 << currentBit));
|
||||||
|
|
||||||
|
Serial.print(">>> CURRENT - Address: ");
|
||||||
|
Serial.print(currentAddr);
|
||||||
|
Serial.print(" | Bit (COM): ");
|
||||||
|
Serial.println(currentBit);
|
||||||
|
Serial.println("Enter 'n' for Next, 'p' for Prev:");
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t shadowRAM[32] = {0};
|
||||||
|
|
||||||
|
void writeMappedSegment(int aab, bool state) {
|
||||||
|
int addr = aab / 10;
|
||||||
|
int bit = aab % 10;
|
||||||
|
|
||||||
|
if (state) shadowRAM[addr] |= (1 << bit);
|
||||||
|
else shadowRAM[addr] &= ~(1 << bit);
|
||||||
|
|
||||||
|
writeAddr(addr, shadowRAM[addr]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void displayDigit(const int segments[], int number) {
|
||||||
|
uint8_t bits = digitMap[number % 10];
|
||||||
|
for (int i = 0; i < 7; i++) {
|
||||||
|
// We check Bit 0, then Bit 1, etc.
|
||||||
|
// This maps i=0 to Segment A, i=1 to Segment B...
|
||||||
|
bool state = (bits >> i) & 0x01;
|
||||||
|
writeMappedSegment(segments[i], state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void printToRow(int row, long value, int decimalPos = 0) {
|
||||||
|
const int** currentRow;
|
||||||
|
int numDigits;
|
||||||
|
|
||||||
|
// Select row configuration
|
||||||
|
switch(row) {
|
||||||
|
case 1: currentRow = digitsRow1; numDigits = 5; break;
|
||||||
|
case 2: currentRow = digitsRow2; numDigits = 5; break;
|
||||||
|
case 3: currentRow = digitsRow3; numDigits = 6; break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the number right-aligned
|
||||||
|
long tempValue = value;
|
||||||
|
for (int i = numDigits - 1; i >= 0; i--) {
|
||||||
|
if (tempValue > 0 || i == numDigits - 1) { // Show at least one digit
|
||||||
|
displayDigit(currentRow[i], tempValue % 10);
|
||||||
|
tempValue /= 10;
|
||||||
|
} else {
|
||||||
|
// Clear leading zeros (all segments off)
|
||||||
|
for (int s = 0; s < 7; s++) writeMappedSegment(currentRow[i][s], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Decimal Points (if applicable for that row)
|
||||||
|
if (decimalPos > 0 && decimalPos <= 3) {
|
||||||
|
writeMappedSegment(decimals[row-1][decimalPos-1], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setupDisplay() {
|
||||||
|
pinMode(LCD_DATA, OUTPUT);
|
||||||
|
pinMode(LCD_WR, OUTPUT);
|
||||||
|
pinMode(LCD_CS, OUTPUT);
|
||||||
|
digitalWrite(LCD_CS, HIGH); // Initialize serial interface [cite: 443]
|
||||||
|
|
||||||
|
sendCmd(0x01); // SYS EN [cite: 534]
|
||||||
|
sendCmd(0x29); // BIAS 1/3, 4 COM [cite: 420, 544]
|
||||||
|
sendCmd(0x03); // LCD ON [cite: 534]
|
||||||
|
|
||||||
|
updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
const byte ROWS = 4;
|
||||||
|
const byte COLS = 5;
|
||||||
|
|
||||||
|
char keys[ROWS][COLS] = {{'1', '2', '3', 'C', 'T'},
|
||||||
|
{'4', '5', '6', 'A', 'Z'},
|
||||||
|
{'7', '8', '9', 'X', 'Y'},
|
||||||
|
{'0', '.', 'S', 'M', 'L'}};
|
||||||
|
|
||||||
|
byte rowPins[ROWS] = {8,9, 10, 11};
|
||||||
|
byte colPins[COLS] = {12,13,14,15,16};
|
||||||
|
|
||||||
|
Keypad kpd = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
|
||||||
|
|
||||||
|
void playBeep() {
|
||||||
|
analogWrite(BUZZER_PIN, 10);
|
||||||
|
delay(50);
|
||||||
|
analogWrite(BUZZER_PIN, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
long tareOffset = 0;
|
||||||
|
float calibrationFactor =
|
||||||
|
74.17; // 1.0 by default, callibrated by placing 796 weight and callibrating
|
||||||
|
|
||||||
|
long readSD10809() {
|
||||||
|
long data = 0;
|
||||||
|
|
||||||
|
// Wait for DRDY to go LOW
|
||||||
|
uint32_t timeout = millis();
|
||||||
|
while (digitalRead(DOUT_SDI0819) == HIGH) {
|
||||||
|
if (millis() - timeout > 100)
|
||||||
|
return -1; // 20Hz rate is 50ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read 24-bit ADC result [cite: 158, 160]
|
||||||
|
for (int i = 0; i < 24; i++) {
|
||||||
|
digitalWrite(SCLK_SDI0819, HIGH);
|
||||||
|
delayMicroseconds(1);
|
||||||
|
data = (data << 1) | digitalRead(DOUT_SDI0819);
|
||||||
|
digitalWrite(SCLK_SDI0819, LOW);
|
||||||
|
delayMicroseconds(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 3 extra pulses (Total 27) to keep Channel A at 128x Gain [cite: 152,
|
||||||
|
// 161]
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
digitalWrite(SCLK_SDI0819, HIGH);
|
||||||
|
delayMicroseconds(1);
|
||||||
|
digitalWrite(SCLK_SDI0819, LOW);
|
||||||
|
delayMicroseconds(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 24-bit Two's Complement sign extension [cite: 108]
|
||||||
|
if (data & 0x800000)
|
||||||
|
data |= 0xFF000000;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
long getAverageReading(int samples) {
|
||||||
|
long sum = 0;
|
||||||
|
int count = 0;
|
||||||
|
while (count < samples) {
|
||||||
|
long val = readSD10809();
|
||||||
|
if (val != -1) {
|
||||||
|
sum += val;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum / samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tare() {
|
||||||
|
Serial.println("Taring... keep scale still.");
|
||||||
|
tareOffset = getAverageReading(20);
|
||||||
|
Serial.print("New Offset: ");
|
||||||
|
Serial.println(tareOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void calibrate(float knownWeightGrams) {
|
||||||
|
long currentRaw = getAverageReading(20);
|
||||||
|
calibrationFactor = (float)(currentRaw - tareOffset) / knownWeightGrams;
|
||||||
|
Serial.print("Calibration Factor set to: ");
|
||||||
|
Serial.println(calibrationFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setupADC() {
|
||||||
|
pinMode(SCLK_SDI0819, OUTPUT);
|
||||||
|
pinMode(DOUT_SDI0819, INPUT);
|
||||||
|
|
||||||
|
// Give the chip time to stabilize (2 cycles for SD10809) [cite: 142]
|
||||||
|
delay(500);
|
||||||
|
tare();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
setupDisplay();
|
||||||
|
setupADC();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
long raw = readSD10809();
|
||||||
|
|
||||||
|
if (kpd.getKeys()) {
|
||||||
|
for (int i = 0; i < LIST_MAX; i++) {
|
||||||
|
if (kpd.key[i].stateChanged) {
|
||||||
|
String msg = "";
|
||||||
|
switch (kpd.key[i].kstate) {
|
||||||
|
case PRESSED:
|
||||||
|
msg = " PRESSED.";
|
||||||
|
playBeep();
|
||||||
|
break;
|
||||||
|
case HOLD:
|
||||||
|
msg = " HOLD.";
|
||||||
|
break;
|
||||||
|
case RELEASED:
|
||||||
|
msg = " RELEASED.";
|
||||||
|
break;
|
||||||
|
case IDLE:
|
||||||
|
msg = " IDLE.";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg != "") {
|
||||||
|
Serial.print("Key ");
|
||||||
|
Serial.print(kpd.key[i].kchar);
|
||||||
|
Serial.println(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw != -1) {
|
||||||
|
float displayWeight = (raw - tareOffset) / calibrationFactor;
|
||||||
|
|
||||||
|
Serial.print("Weight: ");
|
||||||
|
Serial.print(displayWeight, 2);
|
||||||
|
Serial.println(" g");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example trigger for calibration via Serial
|
||||||
|
if (Serial.available()) {
|
||||||
|
char c = Serial.read();
|
||||||
|
if (c == 't') {
|
||||||
|
tare();
|
||||||
|
}
|
||||||
|
if (c == 'c') {
|
||||||
|
calibrate(796);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
extensions/platformio/UART_SCALE_ALL/test/README
Normal file
11
extensions/platformio/UART_SCALE_ALL/test/README
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
|
Unit Testing is a software testing method by which individual units of
|
||||||
|
source code, sets of one or more MCU program modules together with associated
|
||||||
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
|
in the development cycle.
|
||||||
|
|
||||||
|
More information about PlatformIO Unit Testing:
|
||||||
|
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||||
BIN
extensions/platformio/WIRING.xcf
Normal file
BIN
extensions/platformio/WIRING.xcf
Normal file
Binary file not shown.
5
extensions/platformio/display_driver/.gitignore
vendored
Normal file
5
extensions/platformio/display_driver/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
10
extensions/platformio/display_driver/.vscode/extensions.json
vendored
Normal file
10
extensions/platformio/display_driver/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
37
extensions/platformio/display_driver/include/README
Normal file
37
extensions/platformio/display_driver/include/README
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
This directory is intended for project header files.
|
||||||
|
|
||||||
|
A header file is a file containing C declarations and macro definitions
|
||||||
|
to be shared between several project source files. You request the use of a
|
||||||
|
header file in your project source file (C, C++, etc) located in `src` folder
|
||||||
|
by including it, with the C preprocessing directive `#include'.
|
||||||
|
|
||||||
|
```src/main.c
|
||||||
|
|
||||||
|
#include "header.h"
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including a header file produces the same results as copying the header file
|
||||||
|
into each source file that needs it. Such copying would be time-consuming
|
||||||
|
and error-prone. With a header file, the related declarations appear
|
||||||
|
in only one place. If they need to be changed, they can be changed in one
|
||||||
|
place, and programs that include the header file will automatically use the
|
||||||
|
new version when next recompiled. The header file eliminates the labor of
|
||||||
|
finding and changing all the copies as well as the risk that a failure to
|
||||||
|
find one copy will result in inconsistencies within a program.
|
||||||
|
|
||||||
|
In C, the convention is to give header files names that end with `.h'.
|
||||||
|
|
||||||
|
Read more about using header files in official GCC documentation:
|
||||||
|
|
||||||
|
* Include Syntax
|
||||||
|
* Include Operation
|
||||||
|
* Once-Only Headers
|
||||||
|
* Computed Includes
|
||||||
|
|
||||||
|
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||||
46
extensions/platformio/display_driver/lib/README
Normal file
46
extensions/platformio/display_driver/lib/README
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
This directory is intended for project specific (private) libraries.
|
||||||
|
PlatformIO will compile them to static libraries and link into the executable file.
|
||||||
|
|
||||||
|
The source code of each library should be placed in a separate directory
|
||||||
|
("lib/your_library_name/[Code]").
|
||||||
|
|
||||||
|
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||||
|
|
||||||
|
|--lib
|
||||||
|
| |
|
||||||
|
| |--Bar
|
||||||
|
| | |--docs
|
||||||
|
| | |--examples
|
||||||
|
| | |--src
|
||||||
|
| | |- Bar.c
|
||||||
|
| | |- Bar.h
|
||||||
|
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||||
|
| |
|
||||||
|
| |--Foo
|
||||||
|
| | |- Foo.c
|
||||||
|
| | |- Foo.h
|
||||||
|
| |
|
||||||
|
| |- README --> THIS FILE
|
||||||
|
|
|
||||||
|
|- platformio.ini
|
||||||
|
|--src
|
||||||
|
|- main.c
|
||||||
|
|
||||||
|
Example contents of `src/main.c` using Foo and Bar:
|
||||||
|
```
|
||||||
|
#include <Foo.h>
|
||||||
|
#include <Bar.h>
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||||
|
libraries by scanning project source files.
|
||||||
|
|
||||||
|
More information about PlatformIO Library Dependency Finder
|
||||||
|
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||||
15
extensions/platformio/display_driver/platformio.ini
Normal file
15
extensions/platformio/display_driver/platformio.ini
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
; PlatformIO Project Configuration File
|
||||||
|
;
|
||||||
|
; Build options: build flags, source filter
|
||||||
|
; Upload options: custom upload port, speed and extra flags
|
||||||
|
; Library options: dependencies, extra library storages
|
||||||
|
; Advanced options: extra scripting
|
||||||
|
;
|
||||||
|
; Please visit documentation for the other options and examples
|
||||||
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
|
[env:waveshare_rp2040_zero]
|
||||||
|
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||||
|
board = waveshare_rp2040_zero
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
50
extensions/platformio/display_driver/src/TM1621_Config.h
Normal file
50
extensions/platformio/display_driver/src/TM1621_Config.h
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#ifndef TM1621_CONFIG_H
|
||||||
|
#define TM1621_CONFIG_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
const uint8_t digitMap[] = {
|
||||||
|
0b11111100, // 0
|
||||||
|
0b00000110, // 1
|
||||||
|
0b01011011, // 2
|
||||||
|
0b01001111, // 3
|
||||||
|
0b01100110, // 4
|
||||||
|
0b01101101, // 5
|
||||||
|
0b01111101, // 6
|
||||||
|
0b00000111, // 7
|
||||||
|
0b01111111, // 8
|
||||||
|
0b01101111 // 9
|
||||||
|
};
|
||||||
|
|
||||||
|
const int arrows[] = {283,363,183,203}; //tare,?,?,Zero
|
||||||
|
const int battery[] = {63,83,103}; //LOW / MED / FULL
|
||||||
|
|
||||||
|
// Following your pattern: {A, B, C, D, E, F, G}
|
||||||
|
const int row1_d1[] = {300, 301, 302, 313, 312, 310, 311}; // Addresses 30 & 31
|
||||||
|
const int row1_d2[] = {280, 281, 282, 293, 292, 290, 291}; // Addresses 28 & 29
|
||||||
|
const int row1_d3[] = {260, 261, 262, 273, 272, 270, 271}; // Addresses 26 & 27
|
||||||
|
const int row1_d4[] = {240, 241, 242, 253, 252, 250, 251}; // Addresses 24 & 25
|
||||||
|
const int row1_d5[] = {220, 221, 222, 233, 232, 230, 231}; // Addresses 22 & 23
|
||||||
|
const int row1_decimal[] = {263,232,223}; //XX.X.X.X
|
||||||
|
|
||||||
|
const int row2_d1[] = {200, 201, 202, 213, 212, 210, 211}; // Addresses 20 & 21
|
||||||
|
const int row2_d2[] = {180, 181, 182, 193, 192, 190, 191}; // Addresses 18 & 19
|
||||||
|
const int row2_d3[] = {160, 161, 162, 173, 172, 170, 171}; // Addresses 16 & 17
|
||||||
|
const int row2_d4[] = {140, 141, 142, 153, 152, 150, 151}; // Addresses 14 & 15
|
||||||
|
const int row2_d5[] = {120, 121, 122, 133, 132, 130, 131}; // Addresses 12 & 13
|
||||||
|
const int row2_decimal[] = {163,143,123}; //XX.X.X.X
|
||||||
|
|
||||||
|
const int row3_d1[] = {100, 101, 102, 113, 112, 110, 111}; // Addresses 10 & 11
|
||||||
|
const int row3_d2[] = {80, 81, 82, 93, 92, 90, 91}; // Addresses 8 & 9
|
||||||
|
const int row3_d3[] = {60, 61, 62, 73, 72, 70, 71}; // Addresses 6 & 7
|
||||||
|
const int row3_d4[] = {40, 41, 42, 53, 52, 50, 51}; // Addresses 4 & 5
|
||||||
|
const int row3_d5[] = {20, 21, 22, 33, 32, 30, 31}; // Addresses 2 & 3
|
||||||
|
const int row3_d6[] = {0, 1, 2, 13, 12, 10, 11}; // Addresses 0 & 1
|
||||||
|
const int row3_decimal[] = {43,23,3}; //XX.X.X.X
|
||||||
|
|
||||||
|
const int* digitsRow1[] = {row1_d1, row1_d2, row1_d3, row1_d4, row1_d5};
|
||||||
|
const int* digitsRow2[] = {row2_d1, row2_d2, row2_d3, row2_d4, row2_d5};
|
||||||
|
const int* digitsRow3[] = {row3_d1, row3_d2, row3_d3, row3_d4, row3_d5, row3_d6};
|
||||||
|
|
||||||
|
const int* decimals[] = {row1_decimal, row2_decimal, row3_decimal};
|
||||||
|
#endif
|
||||||
175
extensions/platformio/display_driver/src/main.cpp
Normal file
175
extensions/platformio/display_driver/src/main.cpp
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "TM1621_Config.h"
|
||||||
|
|
||||||
|
#define LCD_DATA 29
|
||||||
|
#define LCD_WR 28
|
||||||
|
#define LCD_CS 27
|
||||||
|
|
||||||
|
// Tracking variables
|
||||||
|
int currentAddr = 0;
|
||||||
|
int currentBit = 0;
|
||||||
|
|
||||||
|
void writeBits(uint32_t data, uint8_t count) {
|
||||||
|
for (int8_t i = count - 1; i >= 0; i--) {
|
||||||
|
digitalWrite(LCD_WR, LOW);
|
||||||
|
digitalWrite(LCD_DATA, (data >> i) & 0x01);
|
||||||
|
digitalWrite(LCD_WR, HIGH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendCmd(uint8_t cmd) {
|
||||||
|
digitalWrite(LCD_CS, LOW);
|
||||||
|
writeBits(0x04, 3); // Binary 100 [cite: 432]
|
||||||
|
writeBits(cmd, 8); // Command [cite: 413, 534]
|
||||||
|
writeBits(0, 1); // X bit [cite: 416]
|
||||||
|
digitalWrite(LCD_CS, HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeAddr(uint8_t addr, uint8_t data) {
|
||||||
|
digitalWrite(LCD_CS, LOW);
|
||||||
|
uint16_t header = (0x05 << 6) | (addr & 0x3F); // Mode 101 [cite: 475]
|
||||||
|
writeBits(header, 9);
|
||||||
|
writeBits(data & 0x0F, 4); // 4 bits of data [cite: 475]
|
||||||
|
digitalWrite(LCD_CS, HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateDisplay() {
|
||||||
|
// Clear all segments first
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
writeAddr(i, 0x00);
|
||||||
|
}
|
||||||
|
// Light up only the current bit
|
||||||
|
writeAddr(currentAddr, (1 << currentBit));
|
||||||
|
|
||||||
|
Serial.print(">>> CURRENT - Address: ");
|
||||||
|
Serial.print(currentAddr);
|
||||||
|
Serial.print(" | Bit (COM): ");
|
||||||
|
Serial.println(currentBit);
|
||||||
|
Serial.println("Enter 'n' for Next, 'p' for Prev:");
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
pinMode(LCD_DATA, OUTPUT);
|
||||||
|
pinMode(LCD_WR, OUTPUT);
|
||||||
|
pinMode(LCD_CS, OUTPUT);
|
||||||
|
digitalWrite(LCD_CS, HIGH); // Initialize serial interface [cite: 443]
|
||||||
|
|
||||||
|
sendCmd(0x01); // SYS EN [cite: 534]
|
||||||
|
sendCmd(0x29); // BIAS 1/3, 4 COM [cite: 420, 544]
|
||||||
|
sendCmd(0x03); // LCD ON [cite: 534]
|
||||||
|
|
||||||
|
updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t shadowRAM[32] = {0};
|
||||||
|
|
||||||
|
void writeMappedSegment(int aab, bool state) {
|
||||||
|
int addr = aab / 10;
|
||||||
|
int bit = aab % 10;
|
||||||
|
|
||||||
|
if (state) shadowRAM[addr] |= (1 << bit);
|
||||||
|
else shadowRAM[addr] &= ~(1 << bit);
|
||||||
|
|
||||||
|
writeAddr(addr, shadowRAM[addr]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void displayDigit(const int segments[], int number) {
|
||||||
|
uint8_t bits = digitMap[number % 10];
|
||||||
|
for (int i = 0; i < 7; i++) {
|
||||||
|
// We check Bit 0, then Bit 1, etc.
|
||||||
|
// This maps i=0 to Segment A, i=1 to Segment B...
|
||||||
|
bool state = (bits >> i) & 0x01;
|
||||||
|
writeMappedSegment(segments[i], state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void printToRow(int row, long value, int decimalPos = 0) {
|
||||||
|
const int** currentRow;
|
||||||
|
int numDigits;
|
||||||
|
|
||||||
|
// Select row configuration
|
||||||
|
switch(row) {
|
||||||
|
case 1: currentRow = digitsRow1; numDigits = 5; break;
|
||||||
|
case 2: currentRow = digitsRow2; numDigits = 5; break;
|
||||||
|
case 3: currentRow = digitsRow3; numDigits = 6; break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the number right-aligned
|
||||||
|
long tempValue = value;
|
||||||
|
for (int i = numDigits - 1; i >= 0; i--) {
|
||||||
|
if (tempValue > 0 || i == numDigits - 1) { // Show at least one digit
|
||||||
|
displayDigit(currentRow[i], tempValue % 10);
|
||||||
|
tempValue /= 10;
|
||||||
|
} else {
|
||||||
|
// Clear leading zeros (all segments off)
|
||||||
|
for (int s = 0; s < 7; s++) writeMappedSegment(currentRow[i][s], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Decimal Points (if applicable for that row)
|
||||||
|
if (decimalPos > 0 && decimalPos <= 3) {
|
||||||
|
writeMappedSegment(decimals[row-1][decimalPos-1], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int counter1 = 0;
|
||||||
|
int counter2 = 0;
|
||||||
|
int counter3 = 0;
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
printToRow(1, counter1);
|
||||||
|
printToRow(2, counter2);
|
||||||
|
printToRow(3, counter3);
|
||||||
|
|
||||||
|
counter1 = (counter1 + 1) % 1000;
|
||||||
|
counter2 = (counter2 + 2) % 1000;
|
||||||
|
counter3 = (counter3 + 3) % 1000;
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// void loop() {
|
||||||
|
// writeAddr(0, 0x00);
|
||||||
|
// writeAddr(1, 0x00);
|
||||||
|
// writeAddr(2, 0x00);
|
||||||
|
// writeAddr(3, 0x00);
|
||||||
|
|
||||||
|
// displayDigit(row1_d1, counter);
|
||||||
|
|
||||||
|
// Serial.println(counter);
|
||||||
|
|
||||||
|
// counter++;
|
||||||
|
// if (counter > 9) {
|
||||||
|
// counter = 0;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// delay(1000);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// void loop() {
|
||||||
|
// if (Serial.available() > 0) {
|
||||||
|
// char input = Serial.read();
|
||||||
|
|
||||||
|
// // Ignore newline/carriage return characters
|
||||||
|
// if (input == '\n' || input == '\r') return;
|
||||||
|
|
||||||
|
// if (input == 'n' || input == 'N') {
|
||||||
|
// currentBit++;
|
||||||
|
// if (currentBit > 3) {
|
||||||
|
// currentBit = 0;
|
||||||
|
// currentAddr++;
|
||||||
|
// }
|
||||||
|
// if (currentAddr > 31) currentAddr = 0;
|
||||||
|
// updateDisplay();
|
||||||
|
// }
|
||||||
|
// else if (input == 'p' || input == 'P') {
|
||||||
|
// currentBit--;
|
||||||
|
// if (currentBit < 0) {
|
||||||
|
// currentBit = 3;
|
||||||
|
// currentAddr--;
|
||||||
|
// }
|
||||||
|
// if (currentAddr < 0) currentAddr = 31;
|
||||||
|
// updateDisplay();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
11
extensions/platformio/display_driver/test/README
Normal file
11
extensions/platformio/display_driver/test/README
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
|
Unit Testing is a software testing method by which individual units of
|
||||||
|
source code, sets of one or more MCU program modules together with associated
|
||||||
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
|
in the development cycle.
|
||||||
|
|
||||||
|
More information about PlatformIO Unit Testing:
|
||||||
|
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||||
5
extensions/platformio/keypad_read/.gitignore
vendored
Normal file
5
extensions/platformio/keypad_read/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
10
extensions/platformio/keypad_read/.vscode/extensions.json
vendored
Normal file
10
extensions/platformio/keypad_read/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
37
extensions/platformio/keypad_read/include/README
Normal file
37
extensions/platformio/keypad_read/include/README
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
This directory is intended for project header files.
|
||||||
|
|
||||||
|
A header file is a file containing C declarations and macro definitions
|
||||||
|
to be shared between several project source files. You request the use of a
|
||||||
|
header file in your project source file (C, C++, etc) located in `src` folder
|
||||||
|
by including it, with the C preprocessing directive `#include'.
|
||||||
|
|
||||||
|
```src/main.c
|
||||||
|
|
||||||
|
#include "header.h"
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including a header file produces the same results as copying the header file
|
||||||
|
into each source file that needs it. Such copying would be time-consuming
|
||||||
|
and error-prone. With a header file, the related declarations appear
|
||||||
|
in only one place. If they need to be changed, they can be changed in one
|
||||||
|
place, and programs that include the header file will automatically use the
|
||||||
|
new version when next recompiled. The header file eliminates the labor of
|
||||||
|
finding and changing all the copies as well as the risk that a failure to
|
||||||
|
find one copy will result in inconsistencies within a program.
|
||||||
|
|
||||||
|
In C, the convention is to give header files names that end with `.h'.
|
||||||
|
|
||||||
|
Read more about using header files in official GCC documentation:
|
||||||
|
|
||||||
|
* Include Syntax
|
||||||
|
* Include Operation
|
||||||
|
* Once-Only Headers
|
||||||
|
* Computed Includes
|
||||||
|
|
||||||
|
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||||
46
extensions/platformio/keypad_read/lib/README
Normal file
46
extensions/platformio/keypad_read/lib/README
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
This directory is intended for project specific (private) libraries.
|
||||||
|
PlatformIO will compile them to static libraries and link into the executable file.
|
||||||
|
|
||||||
|
The source code of each library should be placed in a separate directory
|
||||||
|
("lib/your_library_name/[Code]").
|
||||||
|
|
||||||
|
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||||
|
|
||||||
|
|--lib
|
||||||
|
| |
|
||||||
|
| |--Bar
|
||||||
|
| | |--docs
|
||||||
|
| | |--examples
|
||||||
|
| | |--src
|
||||||
|
| | |- Bar.c
|
||||||
|
| | |- Bar.h
|
||||||
|
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||||
|
| |
|
||||||
|
| |--Foo
|
||||||
|
| | |- Foo.c
|
||||||
|
| | |- Foo.h
|
||||||
|
| |
|
||||||
|
| |- README --> THIS FILE
|
||||||
|
|
|
||||||
|
|- platformio.ini
|
||||||
|
|--src
|
||||||
|
|- main.c
|
||||||
|
|
||||||
|
Example contents of `src/main.c` using Foo and Bar:
|
||||||
|
```
|
||||||
|
#include <Foo.h>
|
||||||
|
#include <Bar.h>
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||||
|
libraries by scanning project source files.
|
||||||
|
|
||||||
|
More information about PlatformIO Library Dependency Finder
|
||||||
|
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||||
16
extensions/platformio/keypad_read/platformio.ini
Normal file
16
extensions/platformio/keypad_read/platformio.ini
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
; PlatformIO Project Configuration File
|
||||||
|
;
|
||||||
|
; Build options: build flags, source filter
|
||||||
|
; Upload options: custom upload port, speed and extra flags
|
||||||
|
; Library options: dependencies, extra library storages
|
||||||
|
; Advanced options: extra scripting
|
||||||
|
;
|
||||||
|
; Please visit documentation for the other options and examples
|
||||||
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
|
[env:waveshare_rp2040_zero]
|
||||||
|
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||||
|
board = waveshare_rp2040_zero
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
lib_deps = https://github.com/Chris--A/Keypad
|
||||||
70
extensions/platformio/keypad_read/src/main.cpp
Normal file
70
extensions/platformio/keypad_read/src/main.cpp
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <Keypad.h>
|
||||||
|
|
||||||
|
#define BUZZER_PIN 9
|
||||||
|
|
||||||
|
const byte ROWS = 4;
|
||||||
|
const byte COLS = 5;
|
||||||
|
|
||||||
|
char keys[ROWS][COLS] = {{'1', '2','3','C','T'},
|
||||||
|
{'4', '5','6','A','Z'},
|
||||||
|
{'7', '8','9','X','Y'},
|
||||||
|
{'0', '.','S','M','L'}};
|
||||||
|
|
||||||
|
byte rowPins[ROWS] = {0,1,2,3};
|
||||||
|
byte colPins[COLS] = {4,5,6,7,8};
|
||||||
|
|
||||||
|
Keypad kpd = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
|
||||||
|
|
||||||
|
unsigned long loopCount = 0;
|
||||||
|
unsigned long startTime;
|
||||||
|
|
||||||
|
void playBeep() {
|
||||||
|
analogWrite(BUZZER_PIN, 10);
|
||||||
|
delay(50);
|
||||||
|
analogWrite(BUZZER_PIN, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
startTime = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
loopCount++;
|
||||||
|
if ((millis() - startTime) > 5000) {
|
||||||
|
Serial.print("Average loops per second = ");
|
||||||
|
Serial.println(loopCount / 5);
|
||||||
|
startTime = millis();
|
||||||
|
loopCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kpd.getKeys()) {
|
||||||
|
for (int i = 0; i < LIST_MAX; i++) {
|
||||||
|
if (kpd.key[i].stateChanged) {
|
||||||
|
String msg = "";
|
||||||
|
switch (kpd.key[i].kstate) {
|
||||||
|
case PRESSED:
|
||||||
|
msg = " PRESSED.";
|
||||||
|
playBeep();
|
||||||
|
break;
|
||||||
|
case HOLD:
|
||||||
|
msg = " HOLD.";
|
||||||
|
break;
|
||||||
|
case RELEASED:
|
||||||
|
msg = " RELEASED.";
|
||||||
|
break;
|
||||||
|
case IDLE:
|
||||||
|
msg = " IDLE.";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg != "") {
|
||||||
|
Serial.print("Key ");
|
||||||
|
Serial.print(kpd.key[i].kchar);
|
||||||
|
Serial.println(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
extensions/platformio/keypad_read/test/README
Normal file
11
extensions/platformio/keypad_read/test/README
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
|
Unit Testing is a software testing method by which individual units of
|
||||||
|
source code, sets of one or more MCU program modules together with associated
|
||||||
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
|
in the development cycle.
|
||||||
|
|
||||||
|
More information about PlatformIO Unit Testing:
|
||||||
|
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||||
BIN
extensions/python/KeyGenerator/PLU+FSMA+list+v1.0.xlsx
Normal file
BIN
extensions/python/KeyGenerator/PLU+FSMA+list+v1.0.xlsx
Normal file
Binary file not shown.
164
extensions/python/KeyGenerator/createPDF.py
Normal file
164
extensions/python/KeyGenerator/createPDF.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import barcode
|
||||||
|
import urllib3
|
||||||
|
import re
|
||||||
|
from barcode.writer import ImageWriter
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
# --- SETTINGS ---
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
JSON_FILE = os.path.join(os.getcwd(), 'curated_list.json')
|
||||||
|
CARD_DIR = os.path.join(os.getcwd(), 'keychain_cards')
|
||||||
|
IMG_CACHE_DIR = os.path.join(os.getcwd(), 'image_cache')
|
||||||
|
OUTPUT_PDF = os.path.join(os.getcwd(), 'keychain_3x3_perfect.pdf')
|
||||||
|
|
||||||
|
# A4 at 300 DPI
|
||||||
|
PAGE_W, PAGE_H = 2480, 3508
|
||||||
|
COLS, ROWS = 3, 3
|
||||||
|
PAGE_MARGIN = 150
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
for d in [CARD_DIR, IMG_CACHE_DIR]:
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
|
||||||
|
def clean_filename(name):
|
||||||
|
return re.sub(r'[\\/*?:"<>|]', "_", name)
|
||||||
|
|
||||||
|
def get_ean_from_plu(plu):
|
||||||
|
return f"000000{str(plu).zfill(4)}00"
|
||||||
|
|
||||||
|
def get_cached_image(url, plu):
|
||||||
|
cache_path = os.path.join(IMG_CACHE_DIR, f"{plu}.jpg")
|
||||||
|
|
||||||
|
# Si el archivo ya existe en el cache, lo usamos sin importar la URL
|
||||||
|
if os.path.exists(cache_path):
|
||||||
|
return cache_path
|
||||||
|
|
||||||
|
# Si no existe y la URL es un placeholder, no podemos descargar nada
|
||||||
|
if url == "URL_PLACEHOLDER":
|
||||||
|
print(f"⚠️ {plu} tiene placeholder y no se encontró en {IMG_CACHE_DIR}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Lógica de descarga original para URLs reales
|
||||||
|
try:
|
||||||
|
headers = {'User-Agent': 'Mozilla/5.0'}
|
||||||
|
res = requests.get(url, headers=headers, timeout=10, verify=False)
|
||||||
|
if res.status_code == 200:
|
||||||
|
with open(cache_path, 'wb') as f:
|
||||||
|
f.write(res.content)
|
||||||
|
return cache_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error descargando {plu}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_card(item):
|
||||||
|
name = item['name']
|
||||||
|
plu = item['plu']
|
||||||
|
img_url = item['image']
|
||||||
|
|
||||||
|
safe_name = clean_filename(name).replace(' ', '_')
|
||||||
|
final_path = os.path.join(CARD_DIR, f"PLU_{plu}_{safe_name}.png")
|
||||||
|
|
||||||
|
if os.path.exists(final_path):
|
||||||
|
return final_path
|
||||||
|
|
||||||
|
local_img_path = get_cached_image(img_url, plu)
|
||||||
|
card = Image.new('RGB', (300, 450), color='white')
|
||||||
|
draw = ImageDraw.Draw(card)
|
||||||
|
draw.rectangle([0, 0, 299, 449], outline="black", width=3)
|
||||||
|
|
||||||
|
if local_img_path:
|
||||||
|
try:
|
||||||
|
img = Image.open(local_img_path).convert("RGB")
|
||||||
|
w, h = img.size
|
||||||
|
size = min(w, h)
|
||||||
|
img = img.crop(((w-size)//2, (h-size)//2, (w+size)//2, (h+size)//2))
|
||||||
|
img = img.resize((200, 200), Image.Resampling.LANCZOS)
|
||||||
|
card.paste(img, (50, 40))
|
||||||
|
except:
|
||||||
|
draw.text((150, 140), "[IMG ERROR]", anchor="mm", fill="red")
|
||||||
|
else:
|
||||||
|
draw.text((150, 140), "[NOT FOUND]", anchor="mm", fill="red")
|
||||||
|
|
||||||
|
try:
|
||||||
|
f_name = ImageFont.truetype("arialbd.ttf", 22)
|
||||||
|
f_plu = ImageFont.truetype("arial.ttf", 18)
|
||||||
|
except:
|
||||||
|
f_name = f_plu = ImageFont.load_default()
|
||||||
|
|
||||||
|
draw.text((150, 260), name.upper(), fill="black", font=f_name, anchor="mm")
|
||||||
|
draw.text((150, 295), f"PLU: {plu}", fill="#333333", font=f_plu, anchor="mm")
|
||||||
|
|
||||||
|
EAN = barcode.get_barcode_class('ean13')
|
||||||
|
ean = EAN(get_ean_from_plu(plu), writer=ImageWriter())
|
||||||
|
tmp = f"tmp_{plu}"
|
||||||
|
ean.save(tmp, options={'module_height': 12.0, 'font_size': 10, 'text_distance': 4})
|
||||||
|
|
||||||
|
if os.path.exists(f"{tmp}.png"):
|
||||||
|
b_img = Image.open(f"{tmp}.png")
|
||||||
|
b_img = b_img.resize((280, 120))
|
||||||
|
card.paste(b_img, (10, 320))
|
||||||
|
os.remove(f"{tmp}.png")
|
||||||
|
|
||||||
|
card.save(final_path)
|
||||||
|
print(f" - Card created: {name} ({plu})")
|
||||||
|
return final_path
|
||||||
|
|
||||||
|
def create_pdf():
|
||||||
|
all_files = sorted([f for f in os.listdir(CARD_DIR) if f.endswith('.png')])
|
||||||
|
if not all_files:
|
||||||
|
print("❌ No cards found to put in PDF.")
|
||||||
|
return
|
||||||
|
|
||||||
|
available_w = PAGE_W - (PAGE_MARGIN * 2)
|
||||||
|
available_h = PAGE_H - (PAGE_MARGIN * 2)
|
||||||
|
slot_w, slot_h = available_w // COLS, available_h // ROWS
|
||||||
|
|
||||||
|
target_w = int(slot_w * 0.9)
|
||||||
|
target_h = int(target_w * (450 / 300))
|
||||||
|
|
||||||
|
if target_h > (slot_h * 0.9):
|
||||||
|
target_h = int(slot_h * 0.9)
|
||||||
|
target_w = int(target_h * (300 / 450))
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
current_page = Image.new('RGB', (PAGE_W, PAGE_H), 'white')
|
||||||
|
|
||||||
|
print(f"📄 Organizing {len(all_files)} cards into {COLS}x{ROWS} grid...")
|
||||||
|
|
||||||
|
for i, filename in enumerate(all_files):
|
||||||
|
item_idx = i % (COLS * ROWS)
|
||||||
|
if item_idx == 0 and i > 0:
|
||||||
|
pages.append(current_page)
|
||||||
|
current_page = Image.new('RGB', (PAGE_W, PAGE_H), 'white')
|
||||||
|
|
||||||
|
row, col = item_idx // COLS, item_idx % COLS
|
||||||
|
img_path = os.path.join(CARD_DIR, filename)
|
||||||
|
card_img = Image.open(img_path).convert('RGB')
|
||||||
|
card_img = card_img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
x = PAGE_MARGIN + (col * slot_w) + (slot_w - target_w) // 2
|
||||||
|
y = PAGE_MARGIN + (row * slot_h) + (slot_h - target_h) // 2
|
||||||
|
current_page.paste(card_img, (x, y))
|
||||||
|
|
||||||
|
pages.append(current_page)
|
||||||
|
pages[0].save(OUTPUT_PDF, save_all=True, append_images=pages[1:], resolution=300.0, quality=100)
|
||||||
|
print(f"✅ Created {OUTPUT_PDF}. Now go print it and stop crying.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not os.path.exists(JSON_FILE):
|
||||||
|
print(f"❌ Missing {JSON_FILE}")
|
||||||
|
else:
|
||||||
|
with open(JSON_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
print(f"Step 1: Processing {len(data)} cards...")
|
||||||
|
for entry in data:
|
||||||
|
generate_card(entry)
|
||||||
|
|
||||||
|
print("\nStep 2: Generating PDF...")
|
||||||
|
create_pdf()
|
||||||
51
extensions/python/KeyGenerator/excel_parser.py
Normal file
51
extensions/python/KeyGenerator/excel_parser.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
file_path = os.path.join(os.getcwd(), 'PLU+FSMA+list+v1.0.xlsx')
|
||||||
|
sheet_name = 'Non FTL'
|
||||||
|
new_url_base = "https://server-ifps.accurateig.com/assets/commodities/"
|
||||||
|
|
||||||
|
def get_one_of_each():
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print("❌ Excel file not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Load Excel
|
||||||
|
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
||||||
|
|
||||||
|
# 2. Drop rows missing the essentials
|
||||||
|
df = df.dropna(subset=['IMAGE', 'PLU', 'COMMODITY'])
|
||||||
|
|
||||||
|
# 3. CRITICAL: Drop duplicates by COMMODITY only
|
||||||
|
# This ignores Variety and Size, giving us exactly one row per fruit type.
|
||||||
|
df_unique = df.drop_duplicates(subset=['COMMODITY'], keep='first')
|
||||||
|
|
||||||
|
data_output = []
|
||||||
|
|
||||||
|
for _, row in df_unique.iterrows():
|
||||||
|
# Extract filename from the messy URL in Excel
|
||||||
|
original_link = str(row['IMAGE'])
|
||||||
|
filename = original_link.split('/')[-1]
|
||||||
|
|
||||||
|
# Build the final working URL
|
||||||
|
image_url = f"{new_url_base}{filename}"
|
||||||
|
|
||||||
|
# Get the clean Commodity name
|
||||||
|
commodity = str(row['COMMODITY']).title()
|
||||||
|
plu_code = str(row['PLU'])
|
||||||
|
|
||||||
|
data_output.append({
|
||||||
|
"name": commodity,
|
||||||
|
"plu": plu_code,
|
||||||
|
"image": image_url
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Save to JSON
|
||||||
|
with open('one_of_each.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data_output, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"✅ Success! Generated 'one_of_each.json' with {len(data_output)} unique commodities.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
get_one_of_each()
|
||||||
36
extensions/python/ScannerCOM/scannerV2.py
Normal file
36
extensions/python/ScannerCOM/scannerV2.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import serial
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def run_bridge():
|
||||||
|
parser = argparse.ArgumentParser(description="Scanner Bridge for the technically impaired")
|
||||||
|
parser.add_argument('--port', default='COM5', help='Serial port (default: COM5)')
|
||||||
|
parser.add_argument('--baud', type=int, default=115200, help='Baud rate (default: 115200)')
|
||||||
|
parser.add_argument('--url', default='https://scanner.sekidesu.xyz/scan', help='Server URL')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ser = serial.Serial(args.port, args.baud, timeout=0.1)
|
||||||
|
print(f"Connected to {args.port} at {args.baud} bauds.")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if ser.in_waiting > 0:
|
||||||
|
barcode = ser.readline().decode('utf-8', errors='ignore').strip()
|
||||||
|
if barcode:
|
||||||
|
print(f"Scanned: {barcode}")
|
||||||
|
try:
|
||||||
|
resp = requests.get(args.url, params={'content': barcode}, timeout=5)
|
||||||
|
print(f"Server responded: {resp.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send to server: {e}")
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
except serial.SerialException as e:
|
||||||
|
print(f"Error opening {args.port}: {e}")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nBridge stopped. Finally.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_bridge()
|
||||||
155
extensions/python/ScannerHID/gui_scanner.py
Normal file
155
extensions/python/ScannerHID/gui_scanner.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
import usb.core
|
||||||
|
import usb.util
|
||||||
|
import usb.backend.libusb1
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
VENDOR_ID = 0xFFFF
|
||||||
|
PRODUCT_ID = 0x0035
|
||||||
|
|
||||||
|
HID_MAP = {
|
||||||
|
4: 'a', 5: 'b', 6: 'c', 7: 'd', 8: 'e', 9: 'f', 10: 'g', 11: 'h', 12: 'i',
|
||||||
|
13: 'j', 14: 'k', 15: 'l', 16: 'm', 17: 'n', 18: 'o', 19: 'p', 20: 'q',
|
||||||
|
21: 'r', 22: 's', 23: 't', 24: 'u', 25: 'v', 26: 'w', 27: 'x', 28: 'y', 29: 'z',
|
||||||
|
30: '1', 31: '2', 32: '3', 33: '4', 34: '5', 35: '6', 36: '7', 37: '8', 38: '9', 39: '0',
|
||||||
|
44: ' ', 45: '-', 46: '=', 55: '.', 56: '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
class POSBridgeApp:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("POS Hardware Bridge")
|
||||||
|
self.root.geometry("500x320")
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
# UI Setup
|
||||||
|
ttk.Label(root, text="Target POS Endpoint:").pack(pady=(15, 2))
|
||||||
|
self.url_var = tk.StringVar(value="https://scanner.sekidesu.xyz/scan")
|
||||||
|
self.url_entry = ttk.Entry(root, textvariable=self.url_var, width=60)
|
||||||
|
self.url_entry.pack(pady=5)
|
||||||
|
|
||||||
|
self.status_var = tk.StringVar(value="Status: Booting...")
|
||||||
|
self.status_label = ttk.Label(root, textvariable=self.status_var, font=("Segoe UI", 10, "bold"))
|
||||||
|
self.status_label.pack(pady=10)
|
||||||
|
|
||||||
|
ttk.Label(root, text="Activity Log:").pack()
|
||||||
|
self.log_listbox = tk.Listbox(root, width=70, height=8, bg="#1e1e1e", fg="#00ff00", font=("Consolas", 9))
|
||||||
|
self.log_listbox.pack(pady=5, padx=10)
|
||||||
|
|
||||||
|
# Bind the close button to kill threads cleanly
|
||||||
|
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
|
||||||
|
|
||||||
|
# Fire up the USB listener in a background thread
|
||||||
|
self.usb_thread = threading.Thread(target=self.usb_listen_loop, daemon=True)
|
||||||
|
self.usb_thread.start()
|
||||||
|
|
||||||
|
def log(self, message):
|
||||||
|
# Tkinter requires GUI updates to happen on the main thread
|
||||||
|
self.root.after(0, self._append_log, message)
|
||||||
|
|
||||||
|
def _append_log(self, message):
|
||||||
|
self.log_listbox.insert(0, time.strftime("[%H:%M:%S] ") + message)
|
||||||
|
if self.log_listbox.size() > 15:
|
||||||
|
self.log_listbox.delete(15)
|
||||||
|
|
||||||
|
def update_status(self, text, color="black"):
|
||||||
|
self.root.after(0, self._set_status, text, color)
|
||||||
|
|
||||||
|
def _set_status(self, text, color):
|
||||||
|
self.status_var.set(f"Status: {text}")
|
||||||
|
self.status_label.config(foreground=color)
|
||||||
|
|
||||||
|
def on_close(self):
|
||||||
|
self.running = False
|
||||||
|
self.root.destroy()
|
||||||
|
|
||||||
|
def send_to_pos(self, barcode):
|
||||||
|
url = self.url_var.get()
|
||||||
|
self.log(f"Captured: {barcode}. Sending...")
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params={'content': barcode}, timeout=3)
|
||||||
|
self.log(f"Success: POS returned {resp.status_code}")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.log(f"HTTP Error: Backend unreachable")
|
||||||
|
|
||||||
|
def usb_listen_loop(self):
|
||||||
|
import sys
|
||||||
|
# PyInstaller extracts files to a temp _MEIPASS folder at runtime
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
else:
|
||||||
|
base_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
dll_path = os.path.join(base_path, "libusb-1.0.dll")
|
||||||
|
|
||||||
|
if not os.path.exists(dll_path):
|
||||||
|
self.update_status(f"CRITICAL: DLL missing at {dll_path}", "red")
|
||||||
|
return
|
||||||
|
|
||||||
|
backend = usb.backend.libusb1.get_backend(find_library=lambda x: dll_path)
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
# Reconnect loop
|
||||||
|
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID, backend=backend)
|
||||||
|
|
||||||
|
if dev is None:
|
||||||
|
self.update_status("Scanner unplugged. Waiting...", "red")
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
dev.set_configuration()
|
||||||
|
cfg = dev.get_active_configuration()
|
||||||
|
intf = cfg[(0,0)]
|
||||||
|
endpoint = usb.util.find_descriptor(
|
||||||
|
intf,
|
||||||
|
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
|
||||||
|
)
|
||||||
|
|
||||||
|
self.update_status("Scanner Locked & Ready", "green")
|
||||||
|
current_barcode = ""
|
||||||
|
|
||||||
|
# Active reading loop
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
data = dev.read(endpoint.bEndpointAddress, endpoint.wMaxPacketSize, timeout=1000)
|
||||||
|
|
||||||
|
keycode = data[2]
|
||||||
|
modifier = data[0]
|
||||||
|
is_shift = modifier == 2 or modifier == 32
|
||||||
|
|
||||||
|
if keycode == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if keycode == 40: # Enter key signifies end of scan
|
||||||
|
if current_barcode:
|
||||||
|
# Spawn a micro-thread for the HTTP request so we don't block the next scan
|
||||||
|
threading.Thread(target=self.send_to_pos, args=(current_barcode,), daemon=True).start()
|
||||||
|
current_barcode = ""
|
||||||
|
elif keycode in HID_MAP:
|
||||||
|
char = HID_MAP[keycode]
|
||||||
|
if is_shift and char.isalpha():
|
||||||
|
char = char.upper()
|
||||||
|
current_barcode += char
|
||||||
|
|
||||||
|
except usb.core.USBError as e:
|
||||||
|
# 10060/110 are normal timeouts when no barcode is being actively scanned
|
||||||
|
if e.args[0] in (10060, 110):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
self.log(f"Hardware interrupt lost. Reconnecting...")
|
||||||
|
break # Breaks inner loop to trigger outer reconnect loop
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"USB Error: {e}")
|
||||||
|
time.sleep(2) # Prevent rapid crash loops
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# You must run pip install requests if you haven't already
|
||||||
|
root = tk.Tk()
|
||||||
|
app = POSBridgeApp(root)
|
||||||
|
root.mainloop()
|
||||||
BIN
extensions/python/ScannerHID/libusb-1.0.dll
Normal file
BIN
extensions/python/ScannerHID/libusb-1.0.dll
Normal file
Binary file not shown.
25
extensions/python/checkHIDDevices.py
Normal file
25
extensions/python/checkHIDDevices.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import usb.core
|
||||||
|
import usb.backend.libusb1
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Grab the exact path to the DLL in your current folder
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
dll_path = os.path.join(current_dir, "libusb-1.0.dll")
|
||||||
|
|
||||||
|
if not os.path.exists(dll_path):
|
||||||
|
print(f"I don't see the DLL at: {dll_path}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Force pyusb to use this specific file
|
||||||
|
backend = usb.backend.libusb1.get_backend(find_library=lambda x: dll_path)
|
||||||
|
|
||||||
|
print("Scanning with forced local DLL backend...")
|
||||||
|
devices = usb.core.find(find_all=True, backend=backend)
|
||||||
|
|
||||||
|
found = False
|
||||||
|
for d in devices:
|
||||||
|
found = True
|
||||||
|
print(f"Found Device -> VID: {hex(d.idVendor)} PID: {hex(d.idProduct)}")
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
print("Python is still blind. The DLL might be the wrong architecture (32-bit vs 64-bit).")
|
||||||
85
extensions/python/hidScanner.py
Normal file
85
extensions/python/hidScanner.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import usb.core
|
||||||
|
import usb.util
|
||||||
|
import usb.backend.libusb1
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Your exact scanner IDs
|
||||||
|
VENDOR_ID = 0xFFFF
|
||||||
|
PRODUCT_ID = 0x0035
|
||||||
|
|
||||||
|
# Basic HID to ASCII translation dictionary
|
||||||
|
HID_MAP = {
|
||||||
|
4: 'a', 5: 'b', 6: 'c', 7: 'd', 8: 'e', 9: 'f', 10: 'g', 11: 'h', 12: 'i',
|
||||||
|
13: 'j', 14: 'k', 15: 'l', 16: 'm', 17: 'n', 18: 'o', 19: 'p', 20: 'q',
|
||||||
|
21: 'r', 22: 's', 23: 't', 24: 'u', 25: 'v', 26: 'w', 27: 'x', 28: 'y', 29: 'z',
|
||||||
|
30: '1', 31: '2', 32: '3', 33: '4', 34: '5', 35: '6', 36: '7', 37: '8', 38: '9', 39: '0',
|
||||||
|
44: ' ', 45: '-', 46: '=', 55: '.', 56: '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Force the local DLL backend
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
dll_path = os.path.join(current_dir, "libusb-1.0.dll")
|
||||||
|
|
||||||
|
if not os.path.exists(dll_path):
|
||||||
|
print(f"Error: Missing {dll_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
backend = usb.backend.libusb1.get_backend(find_library=lambda x: dll_path)
|
||||||
|
|
||||||
|
# Find the scanner using the forced backend
|
||||||
|
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID, backend=backend)
|
||||||
|
|
||||||
|
if dev is None:
|
||||||
|
print("Scanner not found. Check Zadig driver again.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Claim device
|
||||||
|
dev.set_configuration()
|
||||||
|
cfg = dev.get_active_configuration()
|
||||||
|
intf = cfg[(0,0)]
|
||||||
|
|
||||||
|
endpoint = usb.util.find_descriptor(
|
||||||
|
intf,
|
||||||
|
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Scanner locked. Waiting for barcodes...")
|
||||||
|
|
||||||
|
current_barcode = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Read 8 bytes from the scanner
|
||||||
|
data = dev.read(endpoint.bEndpointAddress, endpoint.wMaxPacketSize, timeout=1000)
|
||||||
|
|
||||||
|
keycode = data[2]
|
||||||
|
modifier = data[0]
|
||||||
|
is_shift = modifier == 2 or modifier == 32
|
||||||
|
|
||||||
|
if keycode == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if keycode == 40: # Enter key
|
||||||
|
print(f"Captured Barcode: {current_barcode}")
|
||||||
|
current_barcode = ""
|
||||||
|
elif keycode in HID_MAP:
|
||||||
|
char = HID_MAP[keycode]
|
||||||
|
if is_shift and char.isalpha():
|
||||||
|
char = char.upper()
|
||||||
|
current_barcode += char
|
||||||
|
|
||||||
|
except usb.core.USBError as e:
|
||||||
|
if e.args[0] == 10060 or e.args[0] == 110:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
print(f"USB Error: {e}")
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nExiting and releasing scanner...")
|
||||||
|
usb.util.dispose_resources(dev)
|
||||||
|
break
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
29
extensions/python/migrateLegacyDB.py
Normal file
29
extensions/python/migrateLegacyDB.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_FILE = 'db/pos_database.db'
|
||||||
|
|
||||||
|
def upgrade_db():
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
# Add stock column
|
||||||
|
# conn.execute("ALTER TABLE products ADD COLUMN stock REAL DEFAULT 0")
|
||||||
|
# print("Successfully added 'stock' column.")
|
||||||
|
|
||||||
|
# # App.py also expects unit_type, adding it to prevent future headaches
|
||||||
|
# conn.execute("ALTER TABLE products ADD COLUMN unit_type TEXT DEFAULT 'unit'")
|
||||||
|
# print("Successfully added 'unit_type' column.")
|
||||||
|
>>>>>>> 1a048a0e074ee26bd45dda9731c78c2ecef42fba
|
||||||
|
|
||||||
|
conn.execute("ALTER TABLE dicom ADD COLUMN image_url TEXT;")
|
||||||
|
print("Successfully added 'image_url' column.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration complete. Your data is intact.")
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"Skipped: {e}. (This usually means the columns already exist, so you're fine).")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
upgrade_db()
|
||||||
@@ -2,4 +2,6 @@ Flask==3.1.3
|
|||||||
Flask-Login==0.6.3
|
Flask-Login==0.6.3
|
||||||
Flask-SocketIO==5.6.1
|
Flask-SocketIO==5.6.1
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
eventlet==0.36.1
|
eventlet==0.36.1
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
pywebview==6.2.1
|
||||||
17
static/cookieStuff.js
Normal file
17
static/cookieStuff.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function setCookie(name, value, days = 365) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||||
|
let expires = "expires=" + d.toUTCString();
|
||||||
|
document.cookie = name + "=" + value + ";" + expires + ";path=/;SameSite=Lax";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
let nameEQ = name + "=";
|
||||||
|
let ca = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i];
|
||||||
|
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
|
||||||
|
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
BIN
static/doom.zip
Normal file
BIN
static/doom.zip
Normal file
Binary file not shown.
BIN
static/doom2/DOOM.EXE
Normal file
BIN
static/doom2/DOOM.EXE
Normal file
Binary file not shown.
BIN
static/doom2/DOOM.WAD
Normal file
BIN
static/doom2/DOOM.WAD
Normal file
Binary file not shown.
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
286
static/style.css
Normal file
286
static/style.css
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #ebedef;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #2e3338;
|
||||||
|
--text-muted: #4f5660;
|
||||||
|
--border: #e3e5e8;
|
||||||
|
--navbar-bg: #ffffff;
|
||||||
|
--input-bg: #e3e5e8;
|
||||||
|
--table-head: #f2f3f5;
|
||||||
|
--accent: #5865f2;
|
||||||
|
--accent-hover: #4752c4;
|
||||||
|
--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;
|
||||||
|
--text-main: #dcddde;
|
||||||
|
--text-muted: #b9bbbe;
|
||||||
|
--border: #202225;
|
||||||
|
--navbar-bg: #202225;
|
||||||
|
--input-bg: #202225;
|
||||||
|
--table-head: #292b2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .table {
|
||||||
|
--bs-table-color: var(--text-main);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-close {
|
||||||
|
filter: invert(1) grayscale(100%) brightness(200%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .text-muted {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modal-body .text-muted {
|
||||||
|
color: #f6f6f7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-close {
|
||||||
|
filter: invert(1) grayscale(100%) brightness(200%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-select {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dcddde' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .modal-content {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-circle {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: "gg sans", "Segoe UI", sans-serif;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: var(--navbar-bg) !important;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link,
|
||||||
|
.dropdown-item {
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--input-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.text-danger {
|
||||||
|
color: var(--danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-control:focus {
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-discord {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-discord:hover {
|
||||||
|
background: #c23235;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-tag {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
color: var(--text-main);
|
||||||
|
--bs-table-color: var(--text-main);
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#select-all {
|
||||||
|
transform: scale(1.3);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
background: var(--table-head);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bulk bar ── */
|
||||||
|
.bulk-bar {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar .form-control {
|
||||||
|
width: 110px;
|
||||||
|
background: rgba(0, 0, 0, 0.2) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar .form-control::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-product-prompt {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#display-img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 250px;
|
||||||
|
height: auto;
|
||||||
|
max-height: 250px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.col-barcode {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit-sm,
|
||||||
|
.btn-del-sm {
|
||||||
|
padding: 4px 7px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header,
|
||||||
|
.modal-footer {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: var(--bs-theme-placeholder, invert(0.7) grayscale(100%) brightness(200%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--input-bg) !important;
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
border: none !important;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23adb5bd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
background-position: right 0.75rem center !important;
|
||||||
|
background-size: 16px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 100px auto;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-alert {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
39
static/themeStuff.js
Normal file
39
static/themeStuff.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
function applyTheme(t) {
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
localStorage.setItem('theme', t);
|
||||||
|
|
||||||
|
const isDark = (t === 'dark');
|
||||||
|
const themeIcon = document.getElementById('theme-icon');
|
||||||
|
const themeLabel = document.getElementById('theme-label');
|
||||||
|
|
||||||
|
if (themeIcon) {
|
||||||
|
themeIcon.className = isDark ? 'bi bi-sun me-2' : 'bi bi-moon-stars me-2';
|
||||||
|
}
|
||||||
|
if (themeLabel) {
|
||||||
|
themeLabel.innerText = isDark ? 'Modo Claro' : 'Modo Oscuro';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
applyTheme(savedTheme);
|
||||||
|
} else {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
applyTheme(prefersDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for system theme changes only if the user hasn't set a manual override
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||||
|
if (!localStorage.getItem('theme')) {
|
||||||
|
applyTheme(e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initTheme();
|
||||||
1699
templates/checkout.html
Normal file
1699
templates/checkout.html
Normal file
File diff suppressed because it is too large
Load Diff
589
templates/dicom.html
Normal file
589
templates/dicom.html
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
{% extends "macros/base.html" %}
|
||||||
|
|
||||||
|
{% 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-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 id="debtors-list">
|
||||||
|
{% for d in debtors %}
|
||||||
|
<div class="debtor-item" data-debtor-id="{{ d[0] }}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center gap-3 flex-grow-1 cursor-pointer" onclick="toggleDebtor({{ d[0] }})">
|
||||||
|
<div class="debtor-avatar {% if d[3] > 0 %}bg-danger{% else %}bg-success{% endif %} text-white">
|
||||||
|
<i class="bi bi-person-fill"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="debtor-name">{{ d[1] }}</div>
|
||||||
|
<div class="debtor-contact">{{ d[2] or 'Sin contacto' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end me-3">
|
||||||
|
<div class="debtor-debt {% if d[3] <= 0 %}paid{% endif %} price-cell" data-value="{{ d[3] }}"></div>
|
||||||
|
<small class="text-muted">{% if d[3] > 0 %}Deuda pendiente{% else %}Saldo cero{% endif %}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if d[3] > 0 %}
|
||||||
|
<button class="btn btn-sm btn-success btn-pay-all" onclick="payAllDebtor({{ d[0] }}, {{ d[3] }})" title="Pagar toda la deuda">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Pagar Todo
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-sm btn-outline-danger btn-delete-debtor" onclick="deleteDebtor({{ d[0] }}, '{{ d[1] }}')" title="Eliminar deudor">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
<i class="bi bi-chevron-down text-muted chevron-icon" id="chevron-{{ d[0] }}"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded tickets view -->
|
||||||
|
<div id="debtor-{{ d[0] }}" class="d-none mt-3 pt-3" style="border-top: 1px solid rgba(255,255,255,0.05);">
|
||||||
|
<div id="tickets-container-{{ d[0] }}" class="text-center text-muted py-3">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"></span>Cargando tickets...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-emoji-frown d-block mb-3"></i>
|
||||||
|
<p class="mb-0">No hay deudores registrados</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
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">
|
||||||
|
<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="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-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vuelto Modal -->
|
||||||
|
<div class="modal" id="dicomVueltoModal" tabindex="-1" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title w-100 text-center text-muted text-uppercase" style="font-size: 0.8rem">Pago en Efectivo</h5>
|
||||||
|
<button type="button" class="btn-close position-absolute end-0 top-0 m-3" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center pt-1 pb-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="text-muted small">Total a Pagar:</span><br>
|
||||||
|
<span id="dicom-vuelto-total" class="fs-4 fw-bold" style="color: var(--text-main)">$0</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-start">
|
||||||
|
<label class="text-muted small mb-1">Monto Recibido</label>
|
||||||
|
<input type="text" inputmode="numeric" id="dicom-monto-recibido" class="form-control form-control-lg text-center fw-bold fs-4"
|
||||||
|
placeholder="$0"
|
||||||
|
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : ''; calculateDicomVuelto();">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-center gap-2 mb-3">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(1000)">$1.000</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(2000)">$2.000</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(5000)">$5.000</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(10000)">$10.000</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="setDicomVuelto(20000)">$20.000</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 mb-3" style="background: var(--input-bg); border-radius: 8px">
|
||||||
|
<span class="text-muted small text-uppercase fw-bold">Vuelto a Entregar</span><br>
|
||||||
|
<span id="dicom-vuelto-amount" class="fs-1 fw-bold text-muted">$0</span>
|
||||||
|
</div>
|
||||||
|
<button id="btn-confirm-dicom-vuelto" class="btn btn-success w-100 py-3 fw-bold" onclick="confirmDicomPayment()" disabled>Confirmar Pago</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleDebtor(debtorId) {
|
||||||
|
const container = document.getElementById(`debtor-${debtorId}`);
|
||||||
|
const chevron = document.getElementById(`chevron-${debtorId}`);
|
||||||
|
|
||||||
|
if (container.classList.contains('d-none')) {
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
chevron.classList.add('rotated');
|
||||||
|
expandedDebtorId = debtorId;
|
||||||
|
loadTickets(debtorId);
|
||||||
|
} else {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
chevron.classList.remove('rotated');
|
||||||
|
expandedDebtorId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTickets(debtorId) {
|
||||||
|
const container = document.getElementById(`tickets-container-${debtorId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dicom/debtor/${debtorId}`);
|
||||||
|
const tickets = await res.json();
|
||||||
|
|
||||||
|
if (tickets.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-muted small py-3">No hay tickets registrados</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = tickets.map(ticket => `
|
||||||
|
<div class="ticket-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="ticket-status ${ticket.status}">
|
||||||
|
${ticket.status === 'paid' ? 'Pagado' : ticket.status === 'partial' ? 'Parcial' : 'Pendiente'}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small">${new Date(ticket.date).toLocaleDateString('es-CL', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="text-end me-2">
|
||||||
|
<div class="fw-bold" style="font-size: 1.1rem;">${clp.format(ticket.total)}</div>
|
||||||
|
<small class="text-muted">Pagado: ${clp.format(ticket.amount_paid)}</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteTicket(${ticket.id})" title="Eliminar ticket" style="border-radius: 8px; width: 34px; height: 34px; padding: 0; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div style="background: var(--input-bg); border-radius: 8px; padding: 12px 16px;">
|
||||||
|
${ticket.items.map(item => `
|
||||||
|
<div class="ticket-item-row d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<span class="fw-semibold">${item.qty}x</span>
|
||||||
|
<span class="ms-2">${item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="text-muted">${clp.format(item.subtotal)}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deleteItem(${item.id})" title="Eliminar" style="border-radius: 6px; width: 24px; height: 24px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 0.75rem;">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${ticket.remaining > 0 ? `
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3 pt-3" style="border-top: 1px solid rgba(255,255,255,0.05);">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted small d-block">Restante</span>
|
||||||
|
<span class="text-danger fw-bold" style="font-size: 1.1rem;">${clp.format(ticket.remaining)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-success btn-pay-all" onclick="openPaymentModal(${ticket.id}, ${ticket.remaining})">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Pagar Todo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : '<div class="text-center mt-3"><span class="badge bg-success px-3 py-2"><i class="bi bi-check-circle me-1"></i>Pagado completamente</span></div>'}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
container.innerHTML = '<div class="text-danger small py-3">Error al cargar tickets</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPaymentModal(ticketId, remaining) {
|
||||||
|
currentTicketId = ticketId;
|
||||||
|
payAllMode = false;
|
||||||
|
pendingPaymentAmount = remaining;
|
||||||
|
document.getElementById('payment-remaining-display').innerText = clp.format(remaining);
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPayAllModal(debtorId, totalDebt) {
|
||||||
|
currentDebtorId = debtorId;
|
||||||
|
payAllMode = true;
|
||||||
|
pendingPaymentAmount = totalDebt;
|
||||||
|
document.getElementById('payment-remaining-display').innerText = clp.format(totalDebt);
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('paymentModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus monto recibido when vuelto modal opens
|
||||||
|
document.getElementById('dicomVueltoModal').addEventListener('shown.bs.modal', function() {
|
||||||
|
const input = document.getElementById('dicom-monto-recibido');
|
||||||
|
input.value = '';
|
||||||
|
setTimeout(() => input.focus(), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter key to confirm payment in vuelto modal
|
||||||
|
document.getElementById('dicom-monto-recibido').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter' && !document.getElementById('btn-confirm-dicom-vuelto').disabled) {
|
||||||
|
confirmDicomPayment();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcuts for payment modal
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
const modal = document.getElementById('paymentModal');
|
||||||
|
if (modal.classList.contains('show')) {
|
||||||
|
if (e.key === '1') confirmPayment('efectivo');
|
||||||
|
if (e.key === '2') confirmPayment('tarjeta');
|
||||||
|
if (e.key === '3') confirmPayment('transferencia');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store remaining value for calculation
|
||||||
|
const observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.attributeName === 'data-value') {
|
||||||
|
const el = mutation.target;
|
||||||
|
el.innerText = clp.format(parseFloat(el.dataset.value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.price-cell').forEach(el => {
|
||||||
|
observer.observe(el, { attributes: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete debtor
|
||||||
|
function deleteDebtor(debtorId, debtorName) {
|
||||||
|
showDeleteConfirm(
|
||||||
|
'Eliminar deudor',
|
||||||
|
`¿Eliminar al deudor "${debtorName}" y todos sus tickets? Esta acción no se puede deshacer.`,
|
||||||
|
async function() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dicom/debtor/${debtorId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert('Error: ' + (data.error || 'Error desconocido'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error de conexión');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete ticket
|
||||||
|
function deleteTicket(ticketId) {
|
||||||
|
showDeleteConfirm(
|
||||||
|
'Eliminar ticket',
|
||||||
|
'¿Eliminar este ticket y todos sus productos? Esta acción no se puede deshacer.',
|
||||||
|
async function() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dicom/ticket/${ticketId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
if (expandedDebtorId) {
|
||||||
|
loadTickets(expandedDebtorId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert('Error: ' + (data.error || 'Error desconocido'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error de conexión');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete individual item
|
||||||
|
function deleteItem(itemId) {
|
||||||
|
showDeleteConfirm(
|
||||||
|
'Eliminar producto',
|
||||||
|
'¿Eliminar este producto del ticket? Esta acción no se puede deshacer.',
|
||||||
|
async function() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dicom/item/${itemId}`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
if (expandedDebtorId) {
|
||||||
|
loadTickets(expandedDebtorId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Error desconocido'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error de conexión');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay all tickets for a debtor
|
||||||
|
function payAllDebtor(debtorId, totalDebt) {
|
||||||
|
openPayAllModal(debtorId, totalDebt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPayment(paymentMethod) {
|
||||||
|
const amount = pendingPaymentAmount;
|
||||||
|
|
||||||
|
// For efectivo, open the vuelto modal
|
||||||
|
if (paymentMethod === 'efectivo') {
|
||||||
|
pendingPaymentMethod = paymentMethod;
|
||||||
|
document.getElementById('dicom-vuelto-total').innerText = clp.format(amount);
|
||||||
|
document.getElementById('dicom-monto-recibido').value = '';
|
||||||
|
document.getElementById('dicom-vuelto-amount').innerText = '$0';
|
||||||
|
document.getElementById('btn-confirm-dicom-vuelto').disabled = true;
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomVueltoModal')).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other methods, process directly
|
||||||
|
await processDicomPayment(amount, paymentMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDicomVuelto(amount) {
|
||||||
|
const formatted = amount.toLocaleString('es-CL');
|
||||||
|
document.getElementById('dicom-monto-recibido').value = formatted;
|
||||||
|
calculateDicomVuelto();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDicomVuelto() {
|
||||||
|
const receivedStr = document.getElementById('dicom-monto-recibido').value;
|
||||||
|
const received = parseInt(receivedStr.replace(/\./g, '')) || 0;
|
||||||
|
const total = pendingPaymentAmount;
|
||||||
|
const change = Math.max(0, received - total);
|
||||||
|
document.getElementById('dicom-vuelto-amount').innerText = clp.format(change);
|
||||||
|
document.getElementById('btn-confirm-dicom-vuelto').disabled = received < total;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDicomPayment() {
|
||||||
|
await processDicomPayment(pendingPaymentAmount, pendingPaymentMethod);
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('dicomVueltoModal')).hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processDicomPayment(amount, paymentMethod) {
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (payAllMode) {
|
||||||
|
res = await fetch(`/api/dicom/debtor/${currentDebtorId}/pay-all`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ amount: amount, payment_method: paymentMethod })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`/api/dicom/pay`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ticket_id: currentTicketId, amount: amount, payment_method: paymentMethod })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('paymentModal')).hide();
|
||||||
|
payAllMode = false;
|
||||||
|
// Show success modal
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('dicomSuccessModal')).show();
|
||||||
|
// Reload page after modal hides
|
||||||
|
setTimeout(() => location.reload(), 2500);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert('Error: ' + (data.error || 'Error desconocido'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error de conexión');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
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 %}
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>SekiPOS - Inventory Management</title>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f4f7f6;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.header-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: white;
|
|
||||||
padding: 10px 25px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
.main-container { display: flex; gap: 25px; }
|
|
||||||
.column { flex: 1; min-width: 400px; }
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
padding: 25px;
|
|
||||||
border-radius: 15px;
|
|
||||||
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
#display-img {
|
|
||||||
max-width: 250px;
|
|
||||||
max-height: 250px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.price-tag { font-size: 3em; color: #2c3e50; font-weight: 800; margin: 10px 0; }
|
|
||||||
input {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
margin: 12px 0;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
button { padding: 10px 15px; cursor: pointer; border-radius: 6px; border: none; transition: 0.2s; }
|
|
||||||
.btn-save { background: #2c3e50; color: white; width: 100%; font-size: 1.1em; }
|
|
||||||
.btn-save:hover { background: #34495e; }
|
|
||||||
.btn-edit { background: #f1c40f; color: #000; }
|
|
||||||
.btn-del { background: #e74c3c; color: white; }
|
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; }
|
|
||||||
th, td { padding: 15px; border-bottom: 1px solid #eee; text-align: left; }
|
|
||||||
th { background: #fafafa; }
|
|
||||||
|
|
||||||
#new-product-prompt {
|
|
||||||
display: none;
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
animation: slideDown 0.4s ease;
|
|
||||||
|
|
||||||
/* This is what you were missing */
|
|
||||||
display: none; /* JS will change this to flex */
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from { transform: translateY(-20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="header-bar">
|
|
||||||
<h2 style="margin:0;">SekiPOS v1.0</h2>
|
|
||||||
<div>
|
|
||||||
<span>Usuario: <b>{{ user.username }}</b></span> |
|
|
||||||
<a href="/logout" style="color: #e74c3c; text-decoration: none; font-weight: bold;">Cerrar Sesión</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-container">
|
|
||||||
<div class="column">
|
|
||||||
<!-- Notification for New Items -->
|
|
||||||
<div id="new-product-prompt">
|
|
||||||
<span style="flex-grow: 1;">¡Nuevo! Barcode <b><span id="new-barcode-display"></span></b>. ¿Deseas agregarlo?</span>
|
|
||||||
<button onclick="dismissPrompt()" style="background:rgba(255,255,255,0.2); color:white; margin-left: 15px;">Omitir</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Visual Display Card -->
|
|
||||||
<div class="card" id="scan-display">
|
|
||||||
<h2 style="color: #7f8c8d; margin-top:0;">Último Escaneado</h2>
|
|
||||||
<img id="display-img" src="./static/placeholder.png" alt="Producto">
|
|
||||||
<h1 id="display-name" style="margin: 10px 0;">Escanea un producto</h1>
|
|
||||||
<div class="price-tag" id="display-price">$0</div>
|
|
||||||
<p id="display-barcode" style="color: #bdc3c7; font-family: monospace;"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Card -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 id="form-title" style="margin-top:0;">Agregar/Editar Producto</h3>
|
|
||||||
<form action="/upsert" method="POST" id="product-form">
|
|
||||||
<input type="text" name="barcode" id="form-barcode" placeholder="Barcode" required>
|
|
||||||
<input type="text" name="name" id="form-name" placeholder="Nombre del Producto" required>
|
|
||||||
<input type="number" name="price" id="form-price" placeholder="Precio (CLP)" required>
|
|
||||||
<input type="text" name="image_url" id="form-image" placeholder="URL de Imagen">
|
|
||||||
<button type="submit" class="btn-save">Guardar en Inventario</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column">
|
|
||||||
<div class="card">
|
|
||||||
<h3 style="text-align: left; margin-top:0;">Inventario</h3>
|
|
||||||
<input type="text" id="searchInput" onkeyup="searchTable()" placeholder="Buscar por nombre o código...">
|
|
||||||
|
|
||||||
<table id="inventoryTable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Código</th>
|
|
||||||
<th>Nombre</th>
|
|
||||||
<th>Precio</th>
|
|
||||||
<th>Acciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for p in products %}
|
|
||||||
<tr>
|
|
||||||
<td style="font-family: monospace;">{{ p[0] }}</td>
|
|
||||||
<td>{{ p[1] }}</td>
|
|
||||||
<td class="price-cell" data-value="{{ p[2] }}"></td>
|
|
||||||
<td style="white-space: nowrap;">
|
|
||||||
<button class="btn-edit" onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}')">Editar</button>
|
|
||||||
<form action="/delete/{{ p[0] }}" method="POST" style="display:inline;">
|
|
||||||
<button type="submit" class="btn-del" onclick="return confirm('¿Eliminar producto?')">Borrar</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var socket = io();
|
|
||||||
|
|
||||||
// Chilean Peso Formatter (e.g., 1500 -> $1.500)
|
|
||||||
const clpFormatter = new Intl.NumberFormat('es-CL', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'CLP',
|
|
||||||
minimumFractionDigits: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatTablePrices() {
|
|
||||||
document.querySelectorAll('.price-cell').forEach(td => {
|
|
||||||
const val = parseFloat(td.getAttribute('data-value'));
|
|
||||||
td.innerText = clpFormatter.format(val);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
formatTablePrices();
|
|
||||||
|
|
||||||
socket.on('new_scan', function(data) {
|
|
||||||
dismissPrompt();
|
|
||||||
document.getElementById('display-name').innerText = data.name;
|
|
||||||
document.getElementById('display-price').innerText = clpFormatter.format(data.price);
|
|
||||||
document.getElementById('display-barcode').innerText = data.barcode;
|
|
||||||
document.getElementById('display-img').src = data.image || './static/placeholder.png';
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('scan_error', function(data) {
|
|
||||||
const prompt = document.getElementById('new-product-prompt');
|
|
||||||
document.getElementById('new-barcode-display').innerText = data.barcode;
|
|
||||||
document.getElementById('display-price').innerText = "$ ???";
|
|
||||||
prompt.style.display = 'flex';
|
|
||||||
|
|
||||||
// Update big display card with internet data (if any)
|
|
||||||
if (data.name) {
|
|
||||||
document.getElementById('display-name').innerText = data.name + " (Nuevo)";
|
|
||||||
document.getElementById('display-price').innerText = "$ ???";
|
|
||||||
document.getElementById('display-img').src = data.image || './static/placeholder.png';
|
|
||||||
} else {
|
|
||||||
document.getElementById('display-name').innerText = "Producto Desconocido";
|
|
||||||
document.getElementById('display-img').src = './static/placeholder.png';
|
|
||||||
}
|
|
||||||
document.getElementById('display-barcode').innerText = data.barcode;
|
|
||||||
|
|
||||||
// Pre-fill form
|
|
||||||
document.getElementById('form-barcode').value = data.barcode;
|
|
||||||
document.getElementById('form-name').value = data.name || '';
|
|
||||||
document.getElementById('form-image').value = data.image || '';
|
|
||||||
document.getElementById('form-price').value = '';
|
|
||||||
document.getElementById('form-title').innerText = "Crear Nuevo: " + (data.name || data.barcode);
|
|
||||||
document.getElementById('form-price').focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
function dismissPrompt() {
|
|
||||||
document.getElementById('new-product-prompt').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function editProduct(barcode, name, price, image) {
|
|
||||||
dismissPrompt();
|
|
||||||
document.getElementById('form-barcode').value = barcode;
|
|
||||||
document.getElementById('form-name').value = name;
|
|
||||||
document.getElementById('form-price').value = price;
|
|
||||||
document.getElementById('form-image').value = image;
|
|
||||||
document.getElementById('form-title').innerText = "Editando: " + name;
|
|
||||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchTable() {
|
|
||||||
var input = document.getElementById("searchInput");
|
|
||||||
var filter = input.value.toUpperCase();
|
|
||||||
var tr = document.getElementById("inventoryTable").getElementsByTagName("tr");
|
|
||||||
|
|
||||||
for (var i = 1; i < tr.length; i++) {
|
|
||||||
var content = tr[i].textContent || tr[i].innerText;
|
|
||||||
tr[i].style.display = content.toUpperCase().indexOf(filter) > -1 ? "" : "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle instant updates for deletions without full refresh
|
|
||||||
socket.on('product_deleted', function(data) {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
787
templates/inventory.html
Normal file
787
templates/inventory.html
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
{% extends "macros/base.html" %}
|
||||||
|
{% from 'macros/modals.html' import confirm_modal, scanner_modal %}
|
||||||
|
|
||||||
|
{% block title %}Inventario{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="https://unpkg.com/html5-qrcode"></script>
|
||||||
|
<style>
|
||||||
|
.table th:last-child,
|
||||||
|
.table td:last-child {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
max-width: 200px; /* Adjust based on preference */
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.name-cell {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-asc::after {
|
||||||
|
content: " ↓";
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-desc::after {
|
||||||
|
content: " ↑";
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th[onclick] {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
th[onclick]:hover {
|
||||||
|
background-color: var(--input-bg) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
<!-- ── LEFT COLUMN ── -->
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
|
||||||
|
<!-- New product prompt -->
|
||||||
|
<div id="new-product-prompt"
|
||||||
|
class="new-product-prompt p-3 mb-3 d-none d-flex justify-content-between align-items-center">
|
||||||
|
<span>Nuevo: <b id="new-barcode-display"></b></span>
|
||||||
|
<button onclick="dismissPrompt()" class="btn btn-sm" style="background:rgba(0,0,0,0.25);color:#fff;">
|
||||||
|
Omitir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last scanned card -->
|
||||||
|
<div class="discord-card p-3 mb-3 text-center">
|
||||||
|
<p class="mb-1 fw-semibold"
|
||||||
|
style="color:var(--text-muted); font-size:0.8rem; text-transform:uppercase; letter-spacing:.05em;">
|
||||||
|
Último Escaneado</p>
|
||||||
|
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
|
||||||
|
<h5 id="display-name" class="mb-1">Esperando scan...</h5>
|
||||||
|
<div class="price-tag" id="display-price">$0</div>
|
||||||
|
<p id="display-barcode" class="mb-0 mt-1" style="font-family:monospace; opacity:.5; font-size:.8rem;">
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-accent mb-3 w-100" onclick="startScanner()">
|
||||||
|
<i class="bi bi-qr-code-scan me-2"></i>Escanear con Cámara
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Edit / Create card -->
|
||||||
|
<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">
|
||||||
|
<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="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">
|
||||||
|
<option value="unit">Unidad</option>
|
||||||
|
<option value="kg">Kg</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input class="form-control mb-2" type="number" step="1" name="stock" id="form-stock"
|
||||||
|
placeholder="Stock Inicial">
|
||||||
|
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input class="form-control" type="text" name="image_url" id="form-image" placeholder="URL Imagen">
|
||||||
|
<input type="file" id="camera-input" accept="image/*" capture="environment" style="display: none;"
|
||||||
|
onchange="handleFileUpload(this)">
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
onclick="document.getElementById('camera-input').click()">
|
||||||
|
<i class="bi bi-camera"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-accent flex-grow-1">
|
||||||
|
<i class="bi bi-floppy me-1"></i>Guardar
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="clearForm()">
|
||||||
|
<i class="bi bi-eraser"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── RIGHT COLUMN ── -->
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="discord-card p-3">
|
||||||
|
|
||||||
|
<!-- Bulk actions bar -->
|
||||||
|
<div id="bulk-bar" class="bulk-bar p-2 mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><b id="selected-count">0</b> seleccionados</span>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<input type="number" id="bulk-price-input" class="form-control form-control-sm"
|
||||||
|
placeholder="Precio">
|
||||||
|
<button onclick="applyBulkPrice()" class="btn btn-sm"
|
||||||
|
style="background:#fff; color:var(--accent); font-weight:600;">OK</button>
|
||||||
|
<button onclick="applyBulkDelete()" class="btn btn-sm btn-danger-discord">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="clearSelection()" class="btn btn-sm"
|
||||||
|
style="background: rgba(255,255,255,0.2); color:#fff;">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="position-relative mb-3">
|
||||||
|
<input type="text" id="searchInput" class="form-control pe-5" onkeyup="searchTable()"
|
||||||
|
placeholder="Filtrar productos...">
|
||||||
|
<button class="btn btn-link position-absolute end-0 top-50 translate-middle-y text-muted"
|
||||||
|
onclick="clearSearch()" id="clearSearchBtn" style="display: none; text-decoration: none;">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-borderless mb-0" id="inventoryTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:36px;"><input class="form-check-input" type="checkbox" id="select-all"
|
||||||
|
onclick="toggleAll(this)"></th>
|
||||||
|
<th class="col-barcode" onclick="sortTable(1)">Código</th>
|
||||||
|
<th onclick="sortTable(2)">Nombre</th>
|
||||||
|
<th onclick="sortTable(3)">Stock</th>
|
||||||
|
<th onclick="sortTable(4)">Precio</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr data-barcode="{{ p[0] }}">
|
||||||
|
<td><input class="form-check-input product-checkbox" type="checkbox"
|
||||||
|
onclick="updateBulkBar()"></td>
|
||||||
|
<td class="col-barcode">{{ p[0] }}</td>
|
||||||
|
<td class="name-cell">{{ p[1] }}</td>
|
||||||
|
<td>
|
||||||
|
{% if p[5] == 'kg' %}
|
||||||
|
<span class="text-muted d-inline-block text-center" style="width: 45px;">-</span>
|
||||||
|
{% else %}
|
||||||
|
{{ p[4] | int }} <small class="text-muted">Uni</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="price-cell" data-value="{{ p[2] }}"></td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<button class="btn btn-accent btn-sm"
|
||||||
|
onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}', '{{ p[4] }}', '{{ p[5] }}')"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#editModal">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger-discord btn-sm btn-del-sm" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteModal" data-barcode="{{ p[0] }}" data-name="{{ p[1] }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% call confirm_modal('editModal', 'Editar Producto', 'btn-accent', 'Editar', 'confirmEdit()') %}
|
||||||
|
<p>¿Quieres editar <strong id="editProductName"></strong>?</p>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call confirm_modal('deleteModal', 'Eliminar Producto', 'btn-danger-discord', 'Eliminar', 'confirmDelete()') %}
|
||||||
|
<p>¿Seguro que quieres eliminar <strong id="deleteProductName"></strong>?</p>
|
||||||
|
<p class="text-muted small">Esta acción no se puede deshacer.</p>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call confirm_modal('bulkConfirmModal', 'Confirmar Cambio Masivo', 'btn-accent', 'Confirmar Cambios',
|
||||||
|
'executeBulkPrice()') %}
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-3">Vas a actualizar <strong id="bulk-count-text">0</strong> productos al precio de <strong
|
||||||
|
id="bulk-price-text"></strong>.</p>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call confirm_modal('bulkDeleteModal', 'Confirmar Eliminación Masiva', 'btn-danger-discord', 'Eliminar
|
||||||
|
permanentemente', 'executeBulkDelete()') %}
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-3">Vas a eliminar <strong id="bulk-delete-count">0</strong> productos permanentemente.</p>
|
||||||
|
<p class="text-muted small">Esta acción borrará los datos y las imágenes de la caché.</p>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{{ scanner_modal() }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
/* ── Socket.IO ── */
|
||||||
|
const socket = io();
|
||||||
|
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
function formatAll() {
|
||||||
|
document.querySelectorAll('.price-cell').forEach(td => {
|
||||||
|
td.innerText = clp.format(td.getAttribute('data-value'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
formatAll();
|
||||||
|
|
||||||
|
// Inside socket.on('new_scan')
|
||||||
|
socket.on('new_scan', d => {
|
||||||
|
// Update the "Last Scanned" card
|
||||||
|
document.getElementById('display-name').innerText = d.name;
|
||||||
|
document.getElementById('display-price').innerText = clp.format(d.price);
|
||||||
|
document.getElementById('display-barcode').innerText = d.barcode;
|
||||||
|
document.getElementById('display-img').src = d.image || './static/placeholder.png';
|
||||||
|
|
||||||
|
let title = 'Editando: ' + d.name;
|
||||||
|
if (d.note) title += ` (${d.note})`;
|
||||||
|
|
||||||
|
// Update the actual form
|
||||||
|
updateForm(d.barcode, d.name, d.price, d.image, title, d.stock, d.unit_type);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('scan_error', d => {
|
||||||
|
const prompt = document.getElementById('new-product-prompt');
|
||||||
|
document.getElementById('new-barcode-display').innerText = d.barcode;
|
||||||
|
prompt.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Update the "Last Scanned" card so it doesn't show old data
|
||||||
|
document.getElementById('display-name').innerText = d.name || "Producto Nuevo";
|
||||||
|
document.getElementById('display-price').innerText = clp.format(0);
|
||||||
|
document.getElementById('display-barcode').innerText = d.barcode;
|
||||||
|
document.getElementById('display-img').src = d.image || './static/placeholder.png';
|
||||||
|
|
||||||
|
// Clear the price and set the name in the form
|
||||||
|
updateForm(d.barcode, d.name || '', '', d.image || '', 'Crear: ' + d.barcode);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('scale_update', function (data) {
|
||||||
|
console.log("Current Weight:", data.grams + "g");
|
||||||
|
// If the unit type is 'kg', update the stock field automatically
|
||||||
|
const unitType = document.getElementById('form-unit-type').value;
|
||||||
|
if (unitType === 'kg') {
|
||||||
|
document.getElementById('form-stock').value = data.kilograms;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('scale_update', function (data) {
|
||||||
|
const unitType = document.getElementById('form-unit-type').value;
|
||||||
|
if (unitType === 'kg') {
|
||||||
|
document.getElementById('form-stock').value = data.kilograms;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace your existing updateForm function with this one
|
||||||
|
function updateForm(b, n, p, i, t, stock, unit) {
|
||||||
|
dismissPrompt();
|
||||||
|
document.getElementById('form-barcode').value = b;
|
||||||
|
document.getElementById('form-name').value = n;
|
||||||
|
|
||||||
|
// Force integers here to nuke the decimals once and for all
|
||||||
|
document.getElementById('form-stock').value = stock ? parseInt(stock, 10) : 0;
|
||||||
|
|
||||||
|
document.getElementById('form-unit-type').value = unit || 'unit';
|
||||||
|
document.getElementById('form-image').value = i || '';
|
||||||
|
document.getElementById('form-title').innerText = t;
|
||||||
|
|
||||||
|
// Add a timestamp to the URL if it's a local cache image
|
||||||
|
let displayImg = i || './static/placeholder.png';
|
||||||
|
if (displayImg.includes('/static/cache/')) {
|
||||||
|
displayImg += (displayImg.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form-image').value = i || '';
|
||||||
|
document.getElementById('form-title').innerText = t;
|
||||||
|
document.getElementById('display-img').src = displayImg;
|
||||||
|
document.getElementById('display-name').innerText = n || 'Producto Nuevo';
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissPrompt() {
|
||||||
|
document.getElementById('new-product-prompt').classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editProduct(b, n, p, i, stock, unit) {
|
||||||
|
document.getElementById('editProductName').innerText = n;
|
||||||
|
const modal = document.getElementById('editModal');
|
||||||
|
modal.dataset.barcode = b;
|
||||||
|
modal.dataset.name = n;
|
||||||
|
modal.dataset.price = p;
|
||||||
|
modal.dataset.image = i;
|
||||||
|
modal.dataset.stock = stock;
|
||||||
|
modal.dataset.unit = unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmEdit() {
|
||||||
|
const m = document.getElementById('editModal');
|
||||||
|
updateForm(m.dataset.barcode, m.dataset.name, m.dataset.price, m.dataset.image, 'Editando: ' + m.dataset.name, m.dataset.stock, m.dataset.unit);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
bootstrap.Modal.getInstance(m).hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
const modal = document.getElementById('deleteModal');
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.action = `/delete/${modal.dataset.barcode}`;
|
||||||
|
form.method = 'POST';
|
||||||
|
form.style.display = 'none';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete modal setup
|
||||||
|
document.getElementById('deleteModal').addEventListener('show.bs.modal', e => {
|
||||||
|
const button = e.relatedTarget;
|
||||||
|
document.getElementById('deleteProductName').innerText = button.dataset.name;
|
||||||
|
document.getElementById('deleteModal').dataset.barcode = button.dataset.barcode;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Bulk selection ── */
|
||||||
|
function toggleAll(src) {
|
||||||
|
const visibleRows = document.querySelectorAll('#inventoryTable tbody tr:not([style*="display: none"])');
|
||||||
|
visibleRows.forEach(row => {
|
||||||
|
const cb = row.querySelector('.product-checkbox');
|
||||||
|
if (cb) cb.checked = src.checked;
|
||||||
|
});
|
||||||
|
updateBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
|
document.getElementById('selected-count').innerText =
|
||||||
|
document.querySelectorAll('.product-checkbox:checked').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
document.getElementById('display-name').innerText = 'Esperando scan...';
|
||||||
|
document.getElementById('display-price').innerText = '$0';
|
||||||
|
document.getElementById('display-barcode').innerText = '';
|
||||||
|
|
||||||
|
toggleStockInput(); // Show/hide stock input based on default unit type
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(input) {
|
||||||
|
const barcode = document.getElementById('form-barcode').value;
|
||||||
|
if (!barcode) {
|
||||||
|
alert("Primero escanea o ingresa un código de barras.");
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Show a "loading" state if you feel like being fancy
|
||||||
|
const originalBtnContent = document.querySelector('button[onclick*="camera-input"]').innerHTML;
|
||||||
|
document.querySelector('button[onclick*="camera-input"]').innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const compressedBlob = await compressImage(file, 800, 0.7); // Max 800px, 70% quality
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', compressedBlob, `photo_${barcode}.jpg`);
|
||||||
|
formData.append('barcode', barcode);
|
||||||
|
|
||||||
|
const res = await fetch('/upload_image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('form-image').value = data.image_url;
|
||||||
|
document.getElementById('display-img').src = data.image_url;
|
||||||
|
} else {
|
||||||
|
alert("Error al subir imagen: " + data.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("Error procesando imagen.");
|
||||||
|
} finally {
|
||||||
|
document.querySelector('button[onclick*="camera-input"]').innerHTML = originalBtnContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compression engine
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
img.onerror = err => reject(err);
|
||||||
|
};
|
||||||
|
reader.onerror = err => reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBulkPrice() {
|
||||||
|
const price = document.getElementById('bulk-price-input').value;
|
||||||
|
const checked = document.querySelectorAll('.product-checkbox:checked');
|
||||||
|
|
||||||
|
if (!price || checked.length === 0) return;
|
||||||
|
|
||||||
|
// Set text in the pretty modal
|
||||||
|
document.getElementById('bulk-count-text').innerText = checked.length;
|
||||||
|
document.getElementById('bulk-price-text').innerText = clp.format(price);
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const bulkModal = new bootstrap.Modal(document.getElementById('bulkConfirmModal'));
|
||||||
|
bulkModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeBulkPrice() {
|
||||||
|
const price = document.getElementById('bulk-price-input').value;
|
||||||
|
const checked = document.querySelectorAll('.product-checkbox:checked');
|
||||||
|
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
|
||||||
|
|
||||||
|
const res = await fetch('/bulk_price_update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ barcodes, new_price: price })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
checked.forEach(cb => {
|
||||||
|
const cell = cb.closest('tr').querySelector('.price-cell');
|
||||||
|
cell.setAttribute('data-value', price);
|
||||||
|
cell.innerText = clp.format(price);
|
||||||
|
cb.checked = false;
|
||||||
|
});
|
||||||
|
document.getElementById('bulk-price-input').value = '';
|
||||||
|
updateBulkBar();
|
||||||
|
|
||||||
|
// Hide modal
|
||||||
|
const modalEl = document.getElementById('bulkConfirmModal');
|
||||||
|
bootstrap.Modal.getInstance(modalEl).hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBulkDelete() {
|
||||||
|
const checked = document.querySelectorAll('.product-checkbox:checked');
|
||||||
|
if (checked.length === 0) return;
|
||||||
|
|
||||||
|
document.getElementById('bulk-delete-count').innerText = checked.length;
|
||||||
|
const delModal = new bootstrap.Modal(document.getElementById('bulkDeleteModal'));
|
||||||
|
delModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeBulkDelete() {
|
||||||
|
const checked = document.querySelectorAll('.product-checkbox:checked');
|
||||||
|
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
|
||||||
|
|
||||||
|
const res = await fetch('/bulk_delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ barcodes })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
checked.forEach(cb => {
|
||||||
|
cb.closest('tr').remove(); // Remove the row from the table immediately
|
||||||
|
});
|
||||||
|
updateBulkBar();
|
||||||
|
|
||||||
|
const modalEl = document.getElementById('bulkDeleteModal');
|
||||||
|
bootstrap.Modal.getInstance(modalEl).hide();
|
||||||
|
} else {
|
||||||
|
alert("Error al eliminar productos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search ── */
|
||||||
|
function searchTable() {
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
const q = input.value.toUpperCase();
|
||||||
|
const clearBtn = document.getElementById('clearSearchBtn');
|
||||||
|
|
||||||
|
// Show/hide clear button based on input
|
||||||
|
clearBtn.style.display = q.length > 0 ? 'block' : 'none';
|
||||||
|
|
||||||
|
document.querySelectorAll('#inventoryTable tbody tr').forEach(tr => {
|
||||||
|
tr.style.display = tr.innerText.toUpperCase().includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('select-all').checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
input.value = '';
|
||||||
|
searchTable();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortDirections = [true, true, true, true, true];
|
||||||
|
|
||||||
|
function sortTable(colIdx) {
|
||||||
|
const table = document.getElementById("inventoryTable");
|
||||||
|
const tbody = table.querySelector("tbody");
|
||||||
|
const ths = table.querySelectorAll("thead th");
|
||||||
|
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||||
|
const isAscending = sortDirections[colIdx];
|
||||||
|
|
||||||
|
const sortedRows = rows.sort((a, b) => {
|
||||||
|
let valA = a.cells[colIdx].innerText.trim();
|
||||||
|
let valB = b.cells[colIdx].innerText.trim();
|
||||||
|
|
||||||
|
// Specific logic for numeric columns
|
||||||
|
if (colIdx === 3 || colIdx === 4) {
|
||||||
|
// If it's Stock (3) or Price (4), strip everything but numbers
|
||||||
|
// This handles the "Uni" text and the currency symbols
|
||||||
|
valA = parseFloat(valA.replace(/[^\d.-]/g, '')) || 0;
|
||||||
|
valB = parseFloat(valB.replace(/[^\d.-]/g, '')) || 0;
|
||||||
|
} else {
|
||||||
|
valA = valA.toLowerCase();
|
||||||
|
valB = valB.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valA < valB) return isAscending ? -1 : 1;
|
||||||
|
if (valA > valB) return isAscending ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
ths.forEach(th => th.classList.remove('sort-asc', 'sort-desc'));
|
||||||
|
|
||||||
|
ths[colIdx].classList.add(isAscending ? 'sort-asc' : 'sort-desc');
|
||||||
|
|
||||||
|
sortDirections[colIdx] = !isAscending;
|
||||||
|
sortedRows.forEach(row => tbody.appendChild(row));
|
||||||
|
document.getElementById('select-all').checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html5QrCode;
|
||||||
|
let currentCameraId;
|
||||||
|
|
||||||
|
async function startScanner() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('scannerModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
if (!html5QrCode) {
|
||||||
|
html5QrCode = new Html5Qrcode("reader");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await Html5Qrcode.getCameras();
|
||||||
|
const select = document.getElementById('camera-select');
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
if (devices && devices.length) {
|
||||||
|
devices.forEach((device, index) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = device.id;
|
||||||
|
option.dataset.index = index;
|
||||||
|
option.text = device.label || `Cámara ${index + 1}`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedIndex = getCookie('cameraIndex');
|
||||||
|
const targetIndex = (savedIndex !== null && savedIndex < devices.length) ? savedIndex : devices.length - 1;
|
||||||
|
|
||||||
|
currentCameraId = devices[targetIndex].id;
|
||||||
|
select.value = currentCameraId;
|
||||||
|
|
||||||
|
launchCamera(currentCameraId);
|
||||||
|
} else {
|
||||||
|
alert("No se encontraron cámaras.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error obteniendo cámaras:", err);
|
||||||
|
alert("Error de permisos de cámara. Revisa la conexión HTTPS.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let torchEnabled = false;
|
||||||
|
|
||||||
|
function isTorchSupported() {
|
||||||
|
if (!html5QrCode || !html5QrCode.isScanning) return false;
|
||||||
|
const settings = html5QrCode.getRunningTrackSettings();
|
||||||
|
return "torch" in settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTorch() {
|
||||||
|
if (!isTorchSupported()) return;
|
||||||
|
|
||||||
|
torchEnabled = !torchEnabled;
|
||||||
|
try {
|
||||||
|
await html5QrCode.applyVideoConstraints({
|
||||||
|
advanced: [{ torch: torchEnabled }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = document.getElementById('torch-btn');
|
||||||
|
if (torchEnabled) {
|
||||||
|
btn.classList.replace('btn-outline-secondary', 'btn-accent');
|
||||||
|
btn.innerHTML = '<i class="bi bi-lightbulb-fill"></i>';
|
||||||
|
} else {
|
||||||
|
btn.classList.replace('btn-accent', 'btn-outline-secondary');
|
||||||
|
btn.innerHTML = '<i class="bi bi-lightbulb"></i>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Torch error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchCamera(cameraId) {
|
||||||
|
const config = { fps: 10, qrbox: { width: 250, height: 150 } };
|
||||||
|
|
||||||
|
if (html5QrCode.isScanning) {
|
||||||
|
await html5QrCode.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await html5QrCode.start(
|
||||||
|
cameraId,
|
||||||
|
config,
|
||||||
|
(decodedText) => {
|
||||||
|
stopScanner();
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('scannerModal')).hide();
|
||||||
|
fetch(`/scan?content=${decodedText}`)
|
||||||
|
.then(res => {
|
||||||
|
if (res.status === 404) console.log("Nuevo producto detectado");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
html5QrCode.applyVideoConstraints({
|
||||||
|
focusMode: "continuous",
|
||||||
|
advanced: [{ zoom: 2.0 }],
|
||||||
|
});
|
||||||
|
const torchBtn = document.getElementById('torch-btn');
|
||||||
|
if (isTorchSupported()) {
|
||||||
|
torchBtn.style.setProperty('display', 'block', 'important'); // Use !important to override inline styles
|
||||||
|
torchEnabled = false;
|
||||||
|
torchBtn.classList.replace('btn-accent', 'btn-outline-secondary');
|
||||||
|
torchBtn.innerHTML = '<i class="bi bi-lightbulb"></i>';
|
||||||
|
} else {
|
||||||
|
torchBtn.style.display = 'none';
|
||||||
|
console.log("Torch not supported on this device/browser.");
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Camera start error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
if (html5QrCode && html5QrCode.isScanning) {
|
||||||
|
document.getElementById('torch-btn').style.display = 'none';
|
||||||
|
html5QrCode.stop().then(() => {
|
||||||
|
html5QrCode.clear();
|
||||||
|
}).catch(err => console.error("Stop error", err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchCamera(cameraId) {
|
||||||
|
if (cameraId) {
|
||||||
|
const select = document.getElementById('camera-select');
|
||||||
|
const selectedOption = select.options[select.selectedIndex];
|
||||||
|
|
||||||
|
if (selectedOption && selectedOption.dataset.index !== undefined) {
|
||||||
|
setCookie('cameraIndex', selectedOption.dataset.index, 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCameraId = cameraId;
|
||||||
|
launchCamera(cameraId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
if (unitSelect.value === 'kg') {
|
||||||
|
stockInput.classList.add('d-none');
|
||||||
|
stockInput.disabled = true;
|
||||||
|
stockInput.value = '';
|
||||||
|
} else {
|
||||||
|
stockInput.classList.remove('d-none');
|
||||||
|
stockInput.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,25 +1,104 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "macros/base.html" %}
|
||||||
<html>
|
|
||||||
<head>
|
{% block title %}Login{% endblock %}
|
||||||
<title>SekiPOS Login</title>
|
|
||||||
<style>
|
{% block head %}
|
||||||
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #2c3e50; }
|
<style>
|
||||||
.login-box { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.3); text-align: center; }
|
.login-box {
|
||||||
input { display: block; width: 250px; margin: 10px auto; padding: 12px; border: 1px solid #ccc; border-radius: 6px; }
|
position: relative;
|
||||||
button { background: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 6px; cursor: pointer; font-size: 1.1em; }
|
z-index: 10;
|
||||||
</style>
|
}
|
||||||
</head>
|
</style>
|
||||||
<body>
|
{% endblock %}
|
||||||
<div class="login-box">
|
|
||||||
<h2>SekiPOS Access</h2>
|
{% block content %}
|
||||||
{% with messages = get_flashed_messages() %}
|
<div id="tsparticles"></div>
|
||||||
{% if messages %}<p style="color: red;">{{ messages[0] }}</p>{% endif %}
|
|
||||||
{% endwith %}
|
<div class="login-box text-center">
|
||||||
<form method="POST">
|
<h2 class="fw-bold mb-1">SekiPOS</h2>
|
||||||
<input type="text" name="username" placeholder="Username" required>
|
<p class="mb-4 text-muted">¡Hola de nuevo!</p>
|
||||||
<input type="password" name="password" placeholder="Password" required>
|
|
||||||
<button type="submit">Unlock</button>
|
{% with messages = get_flashed_messages() %}
|
||||||
</form>
|
{% if messages %}
|
||||||
</div>
|
<div class="error-alert p-2 mb-3">{{ messages[0] }}</div>
|
||||||
</body>
|
{% endif %}
|
||||||
</html>
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input class="form-control mb-3" type="text" name="username" placeholder="Usuario" required autofocus>
|
||||||
|
<input class="form-control mb-3" type="password" name="password" placeholder="Contraseña" required>
|
||||||
|
<button type="submit" class="btn btn-login w-100">
|
||||||
|
Iniciar Sesión
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
50
templates/macros/base.html
Normal file
50
templates/macros/base.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SekiPOS - {% block title %}{% endblock %}</title>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user