first commit
This commit is contained in:
		
						commit
						ccd85a03fc
					
				| 
						 | 
					@ -0,0 +1,49 @@
 | 
				
			||||||
 | 
					# Byte-compiled / optimized / DLL files
 | 
				
			||||||
 | 
					__pycache__/
 | 
				
			||||||
 | 
					*.py[cod]
 | 
				
			||||||
 | 
					*$py.class
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cấu hình môi trường ảo
 | 
				
			||||||
 | 
					env/
 | 
				
			||||||
 | 
					venv/
 | 
				
			||||||
 | 
					ENV/
 | 
				
			||||||
 | 
					.venv/
 | 
				
			||||||
 | 
					.env/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Pipenv
 | 
				
			||||||
 | 
					Pipfile.lock
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Poetry
 | 
				
			||||||
 | 
					poetry.lock
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cấu hình pytest
 | 
				
			||||||
 | 
					.cache
 | 
				
			||||||
 | 
					.pytest_cache/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cấu hình mypy
 | 
				
			||||||
 | 
					.mypy_cache/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cấu hình coverage
 | 
				
			||||||
 | 
					.coverage
 | 
				
			||||||
 | 
					htmlcov/
 | 
				
			||||||
 | 
					.coverage.*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cấu hình Jupyter Notebook
 | 
				
			||||||
 | 
					.ipynb_checkpoints
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# File log
 | 
				
			||||||
 | 
					*.log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# File SQLite
 | 
				
			||||||
 | 
					*.sqlite3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cấu hình IDE
 | 
				
			||||||
 | 
					.vscode/
 | 
				
			||||||
 | 
					.idea/
 | 
				
			||||||
 | 
					*.sublime-project
 | 
				
			||||||
 | 
					*.sublime-workspace
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# OS specific
 | 
				
			||||||
 | 
					.DS_Store
 | 
				
			||||||
 | 
					Thumbs.db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,14 @@
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import QApplication
 | 
				
			||||||
 | 
					from gui.main_window import MainWindow
 | 
				
			||||||
 | 
					from database.db import create_tables
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main():
 | 
				
			||||||
 | 
					    create_tables()  # tạo bảng nếu chưa tồn tại
 | 
				
			||||||
 | 
					    app = QApplication(sys.argv)
 | 
				
			||||||
 | 
					    window = MainWindow()
 | 
				
			||||||
 | 
					    window.show()
 | 
				
			||||||
 | 
					    sys.exit(app.exec_())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,61 @@
 | 
				
			||||||
 | 
					import sqlite3
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DB_PATH = Path("facebook_marketplace.db")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_connection():
 | 
				
			||||||
 | 
					    conn = sqlite3.connect(DB_PATH)
 | 
				
			||||||
 | 
					    conn.row_factory = sqlite3.Row
 | 
				
			||||||
 | 
					    return conn
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_tables():
 | 
				
			||||||
 | 
					    conn = get_connection()
 | 
				
			||||||
 | 
					    cursor = conn.cursor()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Table quản lý tài khoản Facebook
 | 
				
			||||||
 | 
					    cursor.execute('''
 | 
				
			||||||
 | 
					        CREATE TABLE IF NOT EXISTS accounts (
 | 
				
			||||||
 | 
					            id INTEGER PRIMARY KEY AUTOINCREMENT,
 | 
				
			||||||
 | 
					            email TEXT NOT NULL UNIQUE,
 | 
				
			||||||
 | 
					            password TEXT NOT NULL,
 | 
				
			||||||
 | 
					            is_active INTEGER DEFAULT 1
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    ''')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Table quản lý sản phẩm
 | 
				
			||||||
 | 
					    cursor.execute("""
 | 
				
			||||||
 | 
					        CREATE TABLE IF NOT EXISTS products (
 | 
				
			||||||
 | 
					            id INTEGER PRIMARY KEY AUTOINCREMENT,
 | 
				
			||||||
 | 
					            name TEXT,
 | 
				
			||||||
 | 
					            price REAL,
 | 
				
			||||||
 | 
					            url TEXT,
 | 
				
			||||||
 | 
					            status INTEGER DEFAULT 0,  -- 0 = draft, 1 = published
 | 
				
			||||||
 | 
					            images TEXT,
 | 
				
			||||||
 | 
					            created_at INTEGER,
 | 
				
			||||||
 | 
					            category TEXT DEFAULT NULL,
 | 
				
			||||||
 | 
					            condition TEXT DEFAULT NULL,
 | 
				
			||||||
 | 
					            brand TEXT,
 | 
				
			||||||
 | 
					            description TEXT,
 | 
				
			||||||
 | 
					            tags TEXT DEFAULT NULL,       -- lưu dưới dạng JSON string
 | 
				
			||||||
 | 
					            sku TEXT UNIQUE,
 | 
				
			||||||
 | 
					            location TEXT DEFAULT NULL
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    """)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Table quản lý sản phẩm đã listed bởi account
 | 
				
			||||||
 | 
					    cursor.execute("""
 | 
				
			||||||
 | 
					        CREATE TABLE IF NOT EXISTS listed (
 | 
				
			||||||
 | 
					            id INTEGER PRIMARY KEY AUTOINCREMENT,
 | 
				
			||||||
 | 
					            account_id INTEGER NOT NULL,
 | 
				
			||||||
 | 
					            product_id INTEGER NOT NULL,
 | 
				
			||||||
 | 
					            listed_at INTEGER,
 | 
				
			||||||
 | 
					            status TEXT DEFAULT 'pending',  -- 'pending' hoặc 'listed'
 | 
				
			||||||
 | 
					            UNIQUE(account_id, product_id),
 | 
				
			||||||
 | 
					            FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE,
 | 
				
			||||||
 | 
					            FOREIGN KEY(product_id) REFERENCES products(id) ON DELETE CASCADE
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    """)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    conn.commit()
 | 
				
			||||||
 | 
					    conn.close()
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,2 @@
 | 
				
			||||||
 | 
					from .account import Account
 | 
				
			||||||
 | 
					from .product import Product
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,41 @@
 | 
				
			||||||
 | 
					from database.db import get_connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Account:
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def all():
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("SELECT * FROM accounts")
 | 
				
			||||||
 | 
					        rows = cur.fetchall()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        return rows
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def create(email, password, is_active=1):
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute(
 | 
				
			||||||
 | 
					            "INSERT INTO accounts (email, password, is_active) VALUES (?, ?, ?)",
 | 
				
			||||||
 | 
					            (email, password, is_active)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def update(account_id, email, password, is_active):
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute(
 | 
				
			||||||
 | 
					            "UPDATE accounts SET email = ?, password = ?, is_active = ? WHERE id = ?",
 | 
				
			||||||
 | 
					            (email, password, is_active, account_id)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def delete(account_id):
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,209 @@
 | 
				
			||||||
 | 
					# database/models/listed.py
 | 
				
			||||||
 | 
					import sqlite3
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					from database.db import get_connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Listed:
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def all():
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        conn.row_factory = sqlite3.Row
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("SELECT * FROM listed")
 | 
				
			||||||
 | 
					        rows = cur.fetchall()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        return [dict(row) for row in rows]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_by_account_product(account_id, product_id):
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        conn.row_factory = sqlite3.Row
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute(
 | 
				
			||||||
 | 
					            "SELECT * FROM listed WHERE account_id = ? AND product_id = ?",
 | 
				
			||||||
 | 
					            (account_id, product_id)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        row = cur.fetchone()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        if row:
 | 
				
			||||||
 | 
					            return dict(row)
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def create(account_id, product_id):
 | 
				
			||||||
 | 
					        existing = Listed.get_by_account_product(account_id, product_id)
 | 
				
			||||||
 | 
					        if existing:
 | 
				
			||||||
 | 
					            return existing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        listed_at = int(time.time())
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute(
 | 
				
			||||||
 | 
					            "INSERT INTO listed (account_id, product_id, listed_at) VALUES (?, ?, ?)",
 | 
				
			||||||
 | 
					            (account_id, product_id, listed_at)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        return Listed.get_by_account_product(account_id, product_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def delete(listed_id):
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("DELETE FROM listed WHERE id = ?", (listed_id,))
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_paginated(offset, limit, filters=None, sort_by="id", sort_order="ASC"):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Trả về:
 | 
				
			||||||
 | 
					        - danh sách dict: mỗi dict gồm id, sku, product_name, account_email, listed_at, condition, status, images
 | 
				
			||||||
 | 
					        - total_count: tổng số bản ghi
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        filters = filters or {}
 | 
				
			||||||
 | 
					        params = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        base_query = """
 | 
				
			||||||
 | 
					            SELECT
 | 
				
			||||||
 | 
					                l.id AS id,
 | 
				
			||||||
 | 
					                p.sku AS sku,
 | 
				
			||||||
 | 
					                p.name AS product_name,
 | 
				
			||||||
 | 
					                a.email AS account_email,
 | 
				
			||||||
 | 
					                l.listed_at AS listed_at,
 | 
				
			||||||
 | 
					                l.status AS status,
 | 
				
			||||||
 | 
					                p.condition AS condition,
 | 
				
			||||||
 | 
					                p.images AS images
 | 
				
			||||||
 | 
					            FROM listed l
 | 
				
			||||||
 | 
					            JOIN products p ON l.product_id = p.id
 | 
				
			||||||
 | 
					            JOIN accounts a ON l.account_id = a.id
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Thêm filter nếu có ---
 | 
				
			||||||
 | 
					        where_clauses = []
 | 
				
			||||||
 | 
					        if "status" in filters:
 | 
				
			||||||
 | 
					            where_clauses.append("l.status = ?")
 | 
				
			||||||
 | 
					            params.append(filters["status"])
 | 
				
			||||||
 | 
					        if "account_id" in filters:
 | 
				
			||||||
 | 
					            where_clauses.append("l.account_id = ?")
 | 
				
			||||||
 | 
					            params.append(filters["account_id"])
 | 
				
			||||||
 | 
					        if where_clauses:
 | 
				
			||||||
 | 
					            base_query += " WHERE " + " AND ".join(where_clauses)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Đếm tổng ---
 | 
				
			||||||
 | 
					        total_query = f"SELECT COUNT(*) FROM ({base_query})"
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute(total_query, params)
 | 
				
			||||||
 | 
					        total_count = cur.fetchone()[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Thêm sort + limit + offset ---
 | 
				
			||||||
 | 
					        sort_column_map = {
 | 
				
			||||||
 | 
					            "id": "l.id",
 | 
				
			||||||
 | 
					            "product_name": "p.name",
 | 
				
			||||||
 | 
					            "listed_at": "l.listed_at"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        sort_col = sort_column_map.get(sort_by, "l.id")
 | 
				
			||||||
 | 
					        base_query += f" ORDER BY {sort_col} {sort_order} LIMIT ? OFFSET ?"
 | 
				
			||||||
 | 
					        params.extend([limit, offset])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        cur.execute(base_query, params)
 | 
				
			||||||
 | 
					        rows = cur.fetchall()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Chuyển row thành dict
 | 
				
			||||||
 | 
					        items = []
 | 
				
			||||||
 | 
					        for r in rows:
 | 
				
			||||||
 | 
					            items.append({
 | 
				
			||||||
 | 
					                "id": r[0],
 | 
				
			||||||
 | 
					                "sku": r[1],
 | 
				
			||||||
 | 
					                "product_name": r[2],
 | 
				
			||||||
 | 
					                "account_email": r[3],
 | 
				
			||||||
 | 
					                "listed_at": r[4],
 | 
				
			||||||
 | 
					                "status": r[5],
 | 
				
			||||||
 | 
					                "condition": r[6],
 | 
				
			||||||
 | 
					                "images": r[7]  # giả sử lưu dạng JSON string
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return items, total_count
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def bulk_create(items):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Thêm nhiều bản ghi listed cùng lúc.
 | 
				
			||||||
 | 
					        items = list of dict, mỗi dict có 'account_id' và 'product_id'
 | 
				
			||||||
 | 
					        Nếu đã tồn tại thì bỏ qua, trả về danh sách tất cả bản ghi (mới và đã tồn tại)
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        results = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for item in items:
 | 
				
			||||||
 | 
					            account_id = item.get("account_id")
 | 
				
			||||||
 | 
					            product_id = item.get("product_id")
 | 
				
			||||||
 | 
					            if not account_id or not product_id:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Kiểm tra trùng
 | 
				
			||||||
 | 
					            cur.execute(
 | 
				
			||||||
 | 
					                "SELECT * FROM listed WHERE account_id = ? AND product_id = ?",
 | 
				
			||||||
 | 
					                (account_id, product_id)
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            row = cur.fetchone()
 | 
				
			||||||
 | 
					            if row:
 | 
				
			||||||
 | 
					                results.append(dict(row))
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Insert mới
 | 
				
			||||||
 | 
					            listed_at = int(time.time())
 | 
				
			||||||
 | 
					            cur.execute(
 | 
				
			||||||
 | 
					                "INSERT INTO listed (account_id, product_id, listed_at) VALUES (?, ?, ?)",
 | 
				
			||||||
 | 
					                (account_id, product_id, listed_at)
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            listed_id = cur.lastrowid
 | 
				
			||||||
 | 
					            cur.execute("SELECT * FROM listed WHERE id = ?", (listed_id,))
 | 
				
			||||||
 | 
					            results.append(dict(cur.fetchone()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        return results
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def bulk_delete(ids=None, items=None):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Xóa nhiều bản ghi listed cùng lúc.
 | 
				
			||||||
 | 
					        - ids: list các id của listed
 | 
				
			||||||
 | 
					        - items: list dict {'account_id': ..., 'product_id': ...} để xóa theo cặp
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not ids and not items:
 | 
				
			||||||
 | 
					            return 0  # không có gì để xóa
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        deleted_count = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Xóa theo id
 | 
				
			||||||
 | 
					        if ids:
 | 
				
			||||||
 | 
					            placeholders = ",".join("?" for _ in ids)
 | 
				
			||||||
 | 
					            cur.execute(f"DELETE FROM listed WHERE id IN ({placeholders})", ids)
 | 
				
			||||||
 | 
					            deleted_count += cur.rowcount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Xóa theo account_id + product_id
 | 
				
			||||||
 | 
					        if items:
 | 
				
			||||||
 | 
					            for item in items:
 | 
				
			||||||
 | 
					                account_id = item.get("account_id")
 | 
				
			||||||
 | 
					                product_id = item.get("product_id")
 | 
				
			||||||
 | 
					                if not account_id or not product_id:
 | 
				
			||||||
 | 
					                    continue
 | 
				
			||||||
 | 
					                cur.execute(
 | 
				
			||||||
 | 
					                    "DELETE FROM listed WHERE account_id = ? AND product_id = ?",
 | 
				
			||||||
 | 
					                    (account_id, product_id)
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                deleted_count += cur.rowcount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        return deleted_count
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,208 @@
 | 
				
			||||||
 | 
					# database/models/product.py
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					import sqlite3
 | 
				
			||||||
 | 
					from database.db import get_connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Product:
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def all():
 | 
				
			||||||
 | 
					        """Lấy toàn bộ danh sách sản phẩm"""
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        conn.row_factory = sqlite3.Row
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("SELECT * FROM products")
 | 
				
			||||||
 | 
					        rows = cur.fetchall()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        return [dict(row) for row in rows]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_paginated(offset, limit, filters=None, sort_by="id", sort_order="ASC"):
 | 
				
			||||||
 | 
					        """Lấy danh sách sản phẩm có phân trang, lọc, sắp xếp"""
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        conn.row_factory = sqlite3.Row
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        sql = "SELECT * FROM products WHERE 1=1"
 | 
				
			||||||
 | 
					        params = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Filters
 | 
				
			||||||
 | 
					        if filters:
 | 
				
			||||||
 | 
					            if "name" in filters:
 | 
				
			||||||
 | 
					                sql += " AND name LIKE ?"
 | 
				
			||||||
 | 
					                params.append(f"%{filters['name']}%")
 | 
				
			||||||
 | 
					            if "price" in filters:
 | 
				
			||||||
 | 
					                sql += " AND price = ?"
 | 
				
			||||||
 | 
					                params.append(filters["price"])
 | 
				
			||||||
 | 
					            if "created_at" in filters:
 | 
				
			||||||
 | 
					                sql += " AND DATE(datetime(created_at, 'unixepoch')) = ?"
 | 
				
			||||||
 | 
					                params.append(filters["created_at"])  # YYYY-MM-DD
 | 
				
			||||||
 | 
					            if "category" in filters:
 | 
				
			||||||
 | 
					                sql += " AND category = ?"
 | 
				
			||||||
 | 
					                params.append(filters["category"])
 | 
				
			||||||
 | 
					            if "condition" in filters:
 | 
				
			||||||
 | 
					                sql += " AND condition = ?"
 | 
				
			||||||
 | 
					                params.append(filters["condition"])
 | 
				
			||||||
 | 
					            if "brand" in filters:
 | 
				
			||||||
 | 
					                sql += " AND brand LIKE ?"
 | 
				
			||||||
 | 
					                params.append(f"%{filters['brand']}%")
 | 
				
			||||||
 | 
					            if "tags" in filters:
 | 
				
			||||||
 | 
					                sql += " AND tags LIKE ?"
 | 
				
			||||||
 | 
					                params.append(f"%{filters['tags']}%")
 | 
				
			||||||
 | 
					            if "sku" in filters:
 | 
				
			||||||
 | 
					                sql += " AND sku = ?"
 | 
				
			||||||
 | 
					                params.append(filters["sku"])
 | 
				
			||||||
 | 
					            if "location" in filters:
 | 
				
			||||||
 | 
					                sql += " AND location LIKE ?"
 | 
				
			||||||
 | 
					                params.append(f"%{filters['location']}%")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Count total
 | 
				
			||||||
 | 
					        count_sql = f"SELECT COUNT(*) as total FROM ({sql})"
 | 
				
			||||||
 | 
					        cur.execute(count_sql, params)
 | 
				
			||||||
 | 
					        total_count = cur.fetchone()["total"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Sorting
 | 
				
			||||||
 | 
					        allowed_columns = ["id", "name", "price", "created_at", "category", "condition", "brand"]
 | 
				
			||||||
 | 
					        if sort_by not in allowed_columns:
 | 
				
			||||||
 | 
					            sort_by = "id"
 | 
				
			||||||
 | 
					        sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
 | 
				
			||||||
 | 
					        sql += f" ORDER BY {sort_by} {sort_order}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Pagination
 | 
				
			||||||
 | 
					        sql += " LIMIT ? OFFSET ?"
 | 
				
			||||||
 | 
					        params.extend([limit, offset])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        cur.execute(sql, params)
 | 
				
			||||||
 | 
					        rows = cur.fetchall()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return [dict(row) for row in rows], total_count
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def create(
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        price,
 | 
				
			||||||
 | 
					        images=None,
 | 
				
			||||||
 | 
					        url=None,
 | 
				
			||||||
 | 
					        status="draft",
 | 
				
			||||||
 | 
					        category=None,
 | 
				
			||||||
 | 
					        condition=None,
 | 
				
			||||||
 | 
					        brand=None,
 | 
				
			||||||
 | 
					        description=None,
 | 
				
			||||||
 | 
					        tags=None,
 | 
				
			||||||
 | 
					        sku=None,
 | 
				
			||||||
 | 
					        location=None
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """Tạo mới sản phẩm"""
 | 
				
			||||||
 | 
					        images_json = json.dumps(images or [])
 | 
				
			||||||
 | 
					        tags_json = json.dumps(tags) if tags else None
 | 
				
			||||||
 | 
					        created_at = int(time.time())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("""
 | 
				
			||||||
 | 
					            INSERT INTO products 
 | 
				
			||||||
 | 
					            (name, price, images, url, status, category, condition, brand, description, tags, sku, location, created_at)
 | 
				
			||||||
 | 
					            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 | 
				
			||||||
 | 
					        """, (
 | 
				
			||||||
 | 
					            name, price, images_json, url, status, category,
 | 
				
			||||||
 | 
					            condition, brand, description, tags_json, sku, location, created_at
 | 
				
			||||||
 | 
					        ))
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def update(
 | 
				
			||||||
 | 
					        product_id,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        price,
 | 
				
			||||||
 | 
					        images=None,
 | 
				
			||||||
 | 
					        url=None,
 | 
				
			||||||
 | 
					        status="draft",
 | 
				
			||||||
 | 
					        category=None,
 | 
				
			||||||
 | 
					        condition=None,
 | 
				
			||||||
 | 
					        brand=None,
 | 
				
			||||||
 | 
					        description=None,
 | 
				
			||||||
 | 
					        tags=None,
 | 
				
			||||||
 | 
					        sku=None,
 | 
				
			||||||
 | 
					        location=None
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """Cập nhật sản phẩm"""
 | 
				
			||||||
 | 
					        images_json = json.dumps(images or [])
 | 
				
			||||||
 | 
					        tags_json = json.dumps(tags) if tags else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("""
 | 
				
			||||||
 | 
					            UPDATE products 
 | 
				
			||||||
 | 
					            SET name=?, price=?, images=?, url=?, status=?, category=?, 
 | 
				
			||||||
 | 
					                condition=?, brand=?, description=?, tags=?, sku=?, location=?
 | 
				
			||||||
 | 
					            WHERE id=?
 | 
				
			||||||
 | 
					        """, (
 | 
				
			||||||
 | 
					            name, price, images_json, url, status, category,
 | 
				
			||||||
 | 
					            condition, brand, description, tags_json, sku, location, product_id
 | 
				
			||||||
 | 
					        ))
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def delete(product_id):
 | 
				
			||||||
 | 
					        """Xoá sản phẩm theo ID"""
 | 
				
			||||||
 | 
					        conn = get_connection()
 | 
				
			||||||
 | 
					        cur = conn.cursor()
 | 
				
			||||||
 | 
					        cur.execute("DELETE FROM products WHERE id=?", (product_id,))
 | 
				
			||||||
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def insert_from_import(data):
 | 
				
			||||||
 | 
					        """Thêm sản phẩm khi import từ CSV hoặc API (tái sử dụng hàm create). 
 | 
				
			||||||
 | 
					        Nếu trùng SKU thì trả về bản ghi hiện có mà không lưu mới.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # Parse images từ chuỗi nếu cần
 | 
				
			||||||
 | 
					        images = data.get("images")
 | 
				
			||||||
 | 
					        if isinstance(images, str):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                # thử parse dạng JSON
 | 
				
			||||||
 | 
					                images = json.loads(images)
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                # fallback: tách theo dấu phẩy
 | 
				
			||||||
 | 
					                images = [img.strip() for img in images.split(",") if img.strip()]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        sku = data.get("sku")
 | 
				
			||||||
 | 
					        if sku:
 | 
				
			||||||
 | 
					            existing = Product.get_by_sku(sku)
 | 
				
			||||||
 | 
					            if existing:
 | 
				
			||||||
 | 
					                return existing  # trả về bản ghi đã có
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Nếu chưa tồn tại, tạo mới
 | 
				
			||||||
 | 
					        return Product.create(
 | 
				
			||||||
 | 
					            name=data.get("name"),
 | 
				
			||||||
 | 
					            price=float(data.get("price", 0)),
 | 
				
			||||||
 | 
					            images=images,
 | 
				
			||||||
 | 
					            url=data.get("url"),
 | 
				
			||||||
 | 
					            status=data.get("status", "draft"),
 | 
				
			||||||
 | 
					            category=data.get("category"),
 | 
				
			||||||
 | 
					            condition=data.get("condition"),
 | 
				
			||||||
 | 
					            brand=data.get("brand"),
 | 
				
			||||||
 | 
					            description=data.get("description"),
 | 
				
			||||||
 | 
					            tags=data.get("tags"),
 | 
				
			||||||
 | 
					            sku=sku,
 | 
				
			||||||
 | 
					            location=data.get("location"),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_by_sku(sku):
 | 
				
			||||||
 | 
					        """Trả về dict của sản phẩm theo SKU, hoặc None nếu không tồn tại"""
 | 
				
			||||||
 | 
					        conn = get_connection()  # <-- sửa ở đây
 | 
				
			||||||
 | 
					        conn.row_factory = sqlite3.Row
 | 
				
			||||||
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
 | 
					        cursor.execute("SELECT * FROM products WHERE sku = ?", (sku,))
 | 
				
			||||||
 | 
					        row = cursor.fetchone()
 | 
				
			||||||
 | 
					        conn.close()
 | 
				
			||||||
 | 
					        if row:
 | 
				
			||||||
 | 
					            return dict(row)
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
										
											Binary file not shown.
										
									
								
							| 
						 | 
					@ -0,0 +1,42 @@
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import QMainWindow, QTabWidget
 | 
				
			||||||
 | 
					from gui.tabs.account_tab import AccountTab
 | 
				
			||||||
 | 
					from gui.tabs.products.product_tab import ProductTab
 | 
				
			||||||
 | 
					from gui.tabs.import_tab import ImportTab
 | 
				
			||||||
 | 
					from gui.tabs.listeds.listed_tab import ListedTab
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MainWindow(QMainWindow):
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					        self.setWindowTitle("Facebook Marketplace Manager")
 | 
				
			||||||
 | 
					        self.resize(1200, 600)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Tạo QTabWidget
 | 
				
			||||||
 | 
					        self.tabs = QTabWidget()
 | 
				
			||||||
 | 
					        self.account_tab = AccountTab()
 | 
				
			||||||
 | 
					        self.product_tab = ProductTab()
 | 
				
			||||||
 | 
					        self.import_tab = ImportTab()
 | 
				
			||||||
 | 
					        self.listed_tab = ListedTab()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.tabs.addTab(self.account_tab, "Accounts")
 | 
				
			||||||
 | 
					        self.tabs.addTab(self.product_tab, "Products")
 | 
				
			||||||
 | 
					        self.tabs.addTab(self.listed_tab, "Queue Handle")
 | 
				
			||||||
 | 
					        self.tabs.addTab(self.import_tab, "Import Data")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Gắn event khi tab thay đổi
 | 
				
			||||||
 | 
					        self.tabs.currentChanged.connect(self.on_tab_changed)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.setCentralWidget(self.tabs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Khi mở app thì chỉ load tab đầu tiên (Accounts)
 | 
				
			||||||
 | 
					        self.on_tab_changed(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_tab_changed(self, index):
 | 
				
			||||||
 | 
					        """Chỉ load nội dung tab khi được active."""
 | 
				
			||||||
 | 
					        tab = self.tabs.widget(index)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Mỗi tab có thể có hàm load_data() riêng
 | 
				
			||||||
 | 
					        if hasattr(tab, "load_data"):
 | 
				
			||||||
 | 
					            # Thêm cờ để tránh load lại nhiều lần không cần thiết
 | 
				
			||||||
 | 
					            if not getattr(tab, "is_loaded", False):
 | 
				
			||||||
 | 
					                tab.load_data()
 | 
				
			||||||
 | 
					                tab.is_loaded = True
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,173 @@
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import (
 | 
				
			||||||
 | 
					    QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
 | 
				
			||||||
 | 
					    QPushButton, QHBoxLayout, QDialog, QLabel, QLineEdit, QComboBox, QMessageBox,
 | 
				
			||||||
 | 
					    QMenu, QAction, QSizePolicy
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from PyQt5.QtCore import Qt
 | 
				
			||||||
 | 
					from PyQt5.QtGui import QFont
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import QHeaderView
 | 
				
			||||||
 | 
					from database.models import Account
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					PAGE_SIZE = 10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountForm(QDialog):
 | 
				
			||||||
 | 
					    def __init__(self, parent=None, account=None):
 | 
				
			||||||
 | 
					        super().__init__(parent)
 | 
				
			||||||
 | 
					        self.setWindowTitle("Account Form")
 | 
				
			||||||
 | 
					        self.account = account
 | 
				
			||||||
 | 
					        self.setMinimumSize(400, 200)  # Form to hơn
 | 
				
			||||||
 | 
					        layout = QVBoxLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout.addWidget(QLabel("Email"))
 | 
				
			||||||
 | 
					        self.email_input = QLineEdit()
 | 
				
			||||||
 | 
					        self.email_input.setMinimumWidth(250)
 | 
				
			||||||
 | 
					        layout.addWidget(self.email_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout.addWidget(QLabel("Password"))
 | 
				
			||||||
 | 
					        self.password_input = QLineEdit()
 | 
				
			||||||
 | 
					        layout.addWidget(self.password_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout.addWidget(QLabel("Status"))
 | 
				
			||||||
 | 
					        self.active_input = QComboBox()
 | 
				
			||||||
 | 
					        self.active_input.addItems(["Inactive", "Active"])
 | 
				
			||||||
 | 
					        layout.addWidget(self.active_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        btn_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.save_btn = QPushButton("Save")
 | 
				
			||||||
 | 
					        self.save_btn.setMinimumWidth(80)
 | 
				
			||||||
 | 
					        self.save_btn.clicked.connect(self.save)
 | 
				
			||||||
 | 
					        btn_layout.addWidget(self.save_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.cancel_btn = QPushButton("Cancel")
 | 
				
			||||||
 | 
					        self.cancel_btn.setMinimumWidth(80)
 | 
				
			||||||
 | 
					        self.cancel_btn.clicked.connect(self.close)
 | 
				
			||||||
 | 
					        btn_layout.addWidget(self.cancel_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout.addLayout(btn_layout)
 | 
				
			||||||
 | 
					        self.setLayout(layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # nếu edit account
 | 
				
			||||||
 | 
					        if account:
 | 
				
			||||||
 | 
					            self.email_input.setText(account.get("email", ""))
 | 
				
			||||||
 | 
					            self.password_input.setText(account.get("password", ""))
 | 
				
			||||||
 | 
					            self.active_input.setCurrentText("Active" if account.get("is_active", 1) == 1 else "Inactive")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self):
 | 
				
			||||||
 | 
					        email = self.email_input.text()
 | 
				
			||||||
 | 
					        password = self.password_input.text()
 | 
				
			||||||
 | 
					        is_active = 1 if self.active_input.currentText() == "Active" else 0
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if self.account and "id" in self.account:
 | 
				
			||||||
 | 
					                Account.update(self.account["id"], email, password, is_active)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                Account.create(email, password, is_active)
 | 
				
			||||||
 | 
					            self.accept()
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Error", str(e))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountTab(QWidget):
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					        self.current_page = 0
 | 
				
			||||||
 | 
					        layout = QVBoxLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add button
 | 
				
			||||||
 | 
					        self.add_btn = QPushButton("Add Account")
 | 
				
			||||||
 | 
					        self.add_btn.setMinimumWidth(120)
 | 
				
			||||||
 | 
					        self.add_btn.clicked.connect(self.add_account)
 | 
				
			||||||
 | 
					        layout.addWidget(self.add_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Table
 | 
				
			||||||
 | 
					        self.table = QTableWidget()
 | 
				
			||||||
 | 
					        self.table.verticalHeader().setDefaultSectionSize(28)  # row gọn
 | 
				
			||||||
 | 
					        self.table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
 | 
				
			||||||
 | 
					        layout.addWidget(self.table)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Pagination
 | 
				
			||||||
 | 
					        pag_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.prev_btn = QPushButton("Previous")
 | 
				
			||||||
 | 
					        self.prev_btn.setMinimumWidth(100)
 | 
				
			||||||
 | 
					        self.prev_btn.clicked.connect(self.prev_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.prev_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.next_btn = QPushButton("Next")
 | 
				
			||||||
 | 
					        self.next_btn.setMinimumWidth(100)
 | 
				
			||||||
 | 
					        self.next_btn.clicked.connect(self.next_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.next_btn)
 | 
				
			||||||
 | 
					        layout.addLayout(pag_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.setLayout(layout)
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def load_data(self):
 | 
				
			||||||
 | 
					        accounts = Account.all()
 | 
				
			||||||
 | 
					        start = self.current_page * PAGE_SIZE
 | 
				
			||||||
 | 
					        end = start + PAGE_SIZE
 | 
				
			||||||
 | 
					        page_items = accounts[start:end]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.table.setRowCount(len(page_items))
 | 
				
			||||||
 | 
					        self.table.setColumnCount(4)
 | 
				
			||||||
 | 
					        self.table.setHorizontalHeaderLabels(["ID", "Email", "Status", "Actions"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for i, acc in enumerate(page_items):
 | 
				
			||||||
 | 
					            # convert sqlite3.Row -> dict
 | 
				
			||||||
 | 
					            acc_dict = {k: acc[k] for k in acc.keys()}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.table.setItem(i, 0, QTableWidgetItem(str(acc_dict["id"])))
 | 
				
			||||||
 | 
					            self.table.setItem(i, 1, QTableWidgetItem(acc_dict["email"]))
 | 
				
			||||||
 | 
					            self.table.setItem(i, 2, QTableWidgetItem("Active" if acc_dict["is_active"] == 1 else "Inactive"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # nút menu Actions
 | 
				
			||||||
 | 
					            btn_menu = QPushButton("Actions")
 | 
				
			||||||
 | 
					            menu = QMenu()
 | 
				
			||||||
 | 
					            action_edit = QAction("Edit", btn_menu)
 | 
				
			||||||
 | 
					            action_edit.triggered.connect(lambda _, a=acc_dict: self.edit_account(a))
 | 
				
			||||||
 | 
					            menu.addAction(action_edit)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            action_delete = QAction("Delete", btn_menu)
 | 
				
			||||||
 | 
					            action_delete.triggered.connect(lambda _, a=acc_dict: self.delete_account(a))
 | 
				
			||||||
 | 
					            menu.addAction(action_delete)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            btn_menu.setMenu(menu)
 | 
				
			||||||
 | 
					            self.table.setCellWidget(i, 3, btn_menu)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Phân bổ column width hợp lý
 | 
				
			||||||
 | 
					        header = self.table.horizontalHeader()
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(0, QHeaderView.ResizeToContents)  # ID
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(1, QHeaderView.Stretch)             # Email stretch
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(2, QHeaderView.ResizeToContents)  # Status
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(3, QHeaderView.Fixed)              # Actions fixed width
 | 
				
			||||||
 | 
					        self.table.setColumnWidth(3, 100)                              # 100px cho Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Enable/disable pagination buttons
 | 
				
			||||||
 | 
					        self.prev_btn.setEnabled(self.current_page > 0)
 | 
				
			||||||
 | 
					        self.next_btn.setEnabled(end < len(accounts))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def add_account(self):
 | 
				
			||||||
 | 
					        form = AccountForm(self)
 | 
				
			||||||
 | 
					        if form.exec_():
 | 
				
			||||||
 | 
					            self.current_page = 0
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def edit_account(self, account):
 | 
				
			||||||
 | 
					        form = AccountForm(self, account)
 | 
				
			||||||
 | 
					        if form.exec_():
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete_account(self, account):
 | 
				
			||||||
 | 
					        confirm = QMessageBox.question(
 | 
				
			||||||
 | 
					            self, "Confirm", f"Delete account {account['email']}?", 
 | 
				
			||||||
 | 
					            QMessageBox.Yes | QMessageBox.No
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if confirm == QMessageBox.Yes:
 | 
				
			||||||
 | 
					            Account.delete(account["id"])
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def next_page(self):
 | 
				
			||||||
 | 
					        self.current_page += 1
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def prev_page(self):
 | 
				
			||||||
 | 
					        self.current_page -= 1
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,122 @@
 | 
				
			||||||
 | 
					import csv
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					from services.core.loading_service import run_with_progress
 | 
				
			||||||
 | 
					from database.models.product import Product
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import (
 | 
				
			||||||
 | 
					    QWidget, QVBoxLayout, QPushButton, QFileDialog,
 | 
				
			||||||
 | 
					    QTableWidget, QTableWidgetItem, QHBoxLayout, QMessageBox
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ImportTab(QWidget):
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					        layout = QVBoxLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Action buttons ---
 | 
				
			||||||
 | 
					        btn_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.btn_import_csv = QPushButton("Import from CSV")
 | 
				
			||||||
 | 
					        self.btn_import_csv.clicked.connect(self.import_csv)
 | 
				
			||||||
 | 
					        btn_layout.addWidget(self.btn_import_csv)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.btn_import_api = QPushButton("Import from API")
 | 
				
			||||||
 | 
					        self.btn_import_api.clicked.connect(self.import_api)
 | 
				
			||||||
 | 
					        btn_layout.addWidget(self.btn_import_api)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.btn_save = QPushButton("Save to Database")
 | 
				
			||||||
 | 
					        self.btn_save.clicked.connect(self.save_to_db)
 | 
				
			||||||
 | 
					        self.btn_save.setEnabled(False)
 | 
				
			||||||
 | 
					        btn_layout.addWidget(self.btn_save)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout.addLayout(btn_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Table preview ---
 | 
				
			||||||
 | 
					        self.table = QTableWidget()
 | 
				
			||||||
 | 
					        layout.addWidget(self.table)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.setLayout(layout)
 | 
				
			||||||
 | 
					        self.preview_data = []  # store imported data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def import_csv(self):
 | 
				
			||||||
 | 
					        file_path, _ = QFileDialog.getOpenFileName(self, "Select CSV File", "", "CSV Files (*.csv)")
 | 
				
			||||||
 | 
					        if not file_path:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            # đọc CSV chuẩn với DictReader
 | 
				
			||||||
 | 
					            with open(file_path, newline='', encoding="utf-8-sig") as csvfile:
 | 
				
			||||||
 | 
					                reader = csv.DictReader(csvfile)
 | 
				
			||||||
 | 
					                headers = reader.fieldnames
 | 
				
			||||||
 | 
					                rows = list(reader)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if not headers or not rows:
 | 
				
			||||||
 | 
					                QMessageBox.warning(self, "Warning", "CSV file is empty or invalid.")
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.table.setColumnCount(len(headers))
 | 
				
			||||||
 | 
					            self.table.setHorizontalHeaderLabels(headers)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Hiển thị preview (giới hạn 200 dòng để tránh lag)
 | 
				
			||||||
 | 
					            preview_limit = min(len(rows), 200)
 | 
				
			||||||
 | 
					            self.table.setRowCount(preview_limit)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for i in range(preview_limit):
 | 
				
			||||||
 | 
					                for j, header in enumerate(headers):
 | 
				
			||||||
 | 
					                    value = rows[i].get(header, "")
 | 
				
			||||||
 | 
					                    self.table.setItem(i, j, QTableWidgetItem(value))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.table.resizeColumnsToContents()
 | 
				
			||||||
 | 
					            self.preview_data = rows
 | 
				
			||||||
 | 
					            self.btn_save.setEnabled(True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            QMessageBox.critical(self, "Error", f"Failed to read CSV: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def import_api(self):
 | 
				
			||||||
 | 
					        QMessageBox.information(self, "Info", "API import feature will be developed later 😉")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save_to_db(self):
 | 
				
			||||||
 | 
					        if not self.preview_data:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Warning", "No data to save.")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Xác nhận trước khi import
 | 
				
			||||||
 | 
					        reply = QMessageBox.question(
 | 
				
			||||||
 | 
					            self,
 | 
				
			||||||
 | 
					            "Confirm Import",
 | 
				
			||||||
 | 
					            f"Are you sure you want to import {len(self.preview_data)} rows?",
 | 
				
			||||||
 | 
					            QMessageBox.Yes | QMessageBox.No
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if reply != QMessageBox.Yes:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def handler(item):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                # time.sleep(0.05)  # có thể bỏ nếu không cần debug progress
 | 
				
			||||||
 | 
					                Product.insert_from_import(item)
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                print(f"Failed to import row: {e}")
 | 
				
			||||||
 | 
					                return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        success, fail = run_with_progress(
 | 
				
			||||||
 | 
					            self.preview_data,
 | 
				
			||||||
 | 
					            handler=handler,
 | 
				
			||||||
 | 
					            message="Importing data...",
 | 
				
			||||||
 | 
					            parent=self
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        QMessageBox.information(
 | 
				
			||||||
 | 
					            self,
 | 
				
			||||||
 | 
					            "Import Completed",
 | 
				
			||||||
 | 
					            f"Successfully imported {success}/{len(self.preview_data)} rows.\nFailed: {fail} rows."
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # ✅ Clear preview sau khi import xong
 | 
				
			||||||
 | 
					        self.preview_data.clear()
 | 
				
			||||||
 | 
					        self.table.clearContents()
 | 
				
			||||||
 | 
					        self.table.setRowCount(0)
 | 
				
			||||||
 | 
					        self.table.setColumnCount(0)
 | 
				
			||||||
 | 
					        self.btn_save.setEnabled(False)
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,44 @@
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ListedFilterForm(QWidget):
 | 
				
			||||||
 | 
					    def __init__(self, parent=None):
 | 
				
			||||||
 | 
					        super().__init__(parent)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.setLayout(self.layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # SKU
 | 
				
			||||||
 | 
					        self.sku_filter = QLineEdit()
 | 
				
			||||||
 | 
					        self.sku_filter.setPlaceholderText("SKU")
 | 
				
			||||||
 | 
					        self.layout.addWidget(self.sku_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Product Name
 | 
				
			||||||
 | 
					        self.name_filter = QLineEdit()
 | 
				
			||||||
 | 
					        self.name_filter.setPlaceholderText("Product Name")
 | 
				
			||||||
 | 
					        self.layout.addWidget(self.name_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Account Email
 | 
				
			||||||
 | 
					        self.account_filter = QLineEdit()
 | 
				
			||||||
 | 
					        self.account_filter.setPlaceholderText("Account")
 | 
				
			||||||
 | 
					        self.layout.addWidget(self.account_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Status
 | 
				
			||||||
 | 
					        self.status_filter = QLineEdit()
 | 
				
			||||||
 | 
					        self.status_filter.setPlaceholderText("Status")
 | 
				
			||||||
 | 
					        self.layout.addWidget(self.status_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Apply Filter button
 | 
				
			||||||
 | 
					        self.apply_filter_btn = QPushButton("Apply Filter")
 | 
				
			||||||
 | 
					        self.layout.addWidget(self.apply_filter_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_filters(self):
 | 
				
			||||||
 | 
					        filters = {}
 | 
				
			||||||
 | 
					        if sku := self.sku_filter.text().strip():
 | 
				
			||||||
 | 
					            filters["sku"] = sku
 | 
				
			||||||
 | 
					        if name := self.name_filter.text().strip():
 | 
				
			||||||
 | 
					            filters["product_name"] = name
 | 
				
			||||||
 | 
					        if account := self.account_filter.text().strip():
 | 
				
			||||||
 | 
					            filters["account_email"] = account
 | 
				
			||||||
 | 
					        if status := self.status_filter.text().strip():
 | 
				
			||||||
 | 
					            filters["status"] = status
 | 
				
			||||||
 | 
					        return filters
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,303 @@
 | 
				
			||||||
 | 
					from functools import partial
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import (
 | 
				
			||||||
 | 
					    QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
 | 
				
			||||||
 | 
					    QPushButton, QHBoxLayout, QMenu, QAction, QHeaderView,
 | 
				
			||||||
 | 
					    QLabel, QCheckBox, QSizePolicy, QMessageBox, QStyleOptionButton, QStyle, QStylePainter, QLineEdit
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from PyQt5.QtCore import Qt, QRect, pyqtSignal
 | 
				
			||||||
 | 
					from services.core.loading_service import run_with_progress
 | 
				
			||||||
 | 
					from services.image_service import ImageService
 | 
				
			||||||
 | 
					from database.models.listed import Listed
 | 
				
			||||||
 | 
					from gui.tabs.listed.dialogs.listed_filter_dialog import ListedFilterDialog
 | 
				
			||||||
 | 
					from gui.tabs.listed.dialogs.add_listed_dialog import AddListedDialog
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					PAGE_SIZE = 10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- Header Checkbox ---
 | 
				
			||||||
 | 
					class CheckBoxHeader(QHeaderView):
 | 
				
			||||||
 | 
					    select_all_changed = pyqtSignal(bool)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, orientation, parent=None):
 | 
				
			||||||
 | 
					        super().__init__(orientation, parent)
 | 
				
			||||||
 | 
					        self.isOn = False
 | 
				
			||||||
 | 
					        self.setSectionsClickable(True)
 | 
				
			||||||
 | 
					        self.sectionPressed.connect(self.handle_section_pressed)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def paintSection(self, painter, rect, logicalIndex):
 | 
				
			||||||
 | 
					        super().paintSection(painter, rect, logicalIndex)
 | 
				
			||||||
 | 
					        if logicalIndex == 0:
 | 
				
			||||||
 | 
					            option = QStyleOptionButton()
 | 
				
			||||||
 | 
					            size = 20
 | 
				
			||||||
 | 
					            x = rect.x() + (rect.width() - size) // 2
 | 
				
			||||||
 | 
					            y = rect.y() + (rect.height() - size) // 2
 | 
				
			||||||
 | 
					            option.rect = QRect(x, y, size, size)
 | 
				
			||||||
 | 
					            option.state = QStyle.State_Enabled | (QStyle.State_On if self.isOn else QStyle.State_Off)
 | 
				
			||||||
 | 
					            painter2 = QStylePainter(self.viewport())
 | 
				
			||||||
 | 
					            painter2.drawControl(QStyle.CE_CheckBox, option)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_section_pressed(self, logicalIndex):
 | 
				
			||||||
 | 
					        if logicalIndex == 0:
 | 
				
			||||||
 | 
					            self.isOn = not self.isOn
 | 
				
			||||||
 | 
					            self.select_all_changed.emit(self.isOn)
 | 
				
			||||||
 | 
					            self.viewport().update()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- ListedTab ---
 | 
				
			||||||
 | 
					class ListedTab(QWidget):
 | 
				
			||||||
 | 
					    SORTABLE_COLUMNS = {
 | 
				
			||||||
 | 
					        1: "id",
 | 
				
			||||||
 | 
					        3: "product_name",
 | 
				
			||||||
 | 
					        5: "listed_at"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					        self.current_page = 0
 | 
				
			||||||
 | 
					        self.total_count = 0
 | 
				
			||||||
 | 
					        self.total_pages = 0
 | 
				
			||||||
 | 
					        self.filters = {}
 | 
				
			||||||
 | 
					        self.sort_by = "id"
 | 
				
			||||||
 | 
					        self.sort_order = "ASC"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout = QVBoxLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Top menu
 | 
				
			||||||
 | 
					        top_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        top_layout.addItem(QLabel())  # spacer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.options_btn = QPushButton("Action")
 | 
				
			||||||
 | 
					        self.options_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
 | 
				
			||||||
 | 
					        self.options_btn.setMinimumWidth(50)
 | 
				
			||||||
 | 
					        self.options_btn.setMaximumWidth(120)
 | 
				
			||||||
 | 
					        top_layout.addWidget(self.options_btn)
 | 
				
			||||||
 | 
					        layout.addLayout(top_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Table
 | 
				
			||||||
 | 
					        self.table = QTableWidget()
 | 
				
			||||||
 | 
					        self.table.verticalHeader().setDefaultSectionSize(60)
 | 
				
			||||||
 | 
					        self.table.setEditTriggers(QTableWidget.NoEditTriggers)
 | 
				
			||||||
 | 
					        self.table.setSelectionBehavior(QTableWidget.SelectRows)
 | 
				
			||||||
 | 
					        header = CheckBoxHeader(Qt.Horizontal, self.table)
 | 
				
			||||||
 | 
					        self.table.setHorizontalHeader(header)
 | 
				
			||||||
 | 
					        header.select_all_changed.connect(self.select_all_rows)
 | 
				
			||||||
 | 
					        header.sectionClicked.connect(self.handle_header_click)
 | 
				
			||||||
 | 
					        layout.addWidget(self.table)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Pagination
 | 
				
			||||||
 | 
					        pag_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.prev_btn = QPushButton("Previous")
 | 
				
			||||||
 | 
					        self.prev_btn.clicked.connect(self.prev_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.prev_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.page_info_label = QLabel("Page 1 / 1 (0 items)")
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.page_info_label)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.page_input = QLineEdit()
 | 
				
			||||||
 | 
					        self.page_input.setFixedWidth(50)
 | 
				
			||||||
 | 
					        self.page_input.setPlaceholderText("Page")
 | 
				
			||||||
 | 
					        self.page_input.returnPressed.connect(self.go_to_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.page_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.next_btn = QPushButton("Next")
 | 
				
			||||||
 | 
					        self.next_btn.clicked.connect(self.next_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.next_btn)
 | 
				
			||||||
 | 
					        layout.addLayout(pag_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.setLayout(layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Load Data ---
 | 
				
			||||||
 | 
					    def load_data(self, show_progress=True):
 | 
				
			||||||
 | 
					        self.table.clearContents()
 | 
				
			||||||
 | 
					        self.table.setRowCount(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        offset = self.current_page * PAGE_SIZE
 | 
				
			||||||
 | 
					        page_items, total_count = Listed.get_paginated(
 | 
				
			||||||
 | 
					            offset, PAGE_SIZE, self.filters,
 | 
				
			||||||
 | 
					            sort_by=self.sort_by, sort_order=self.sort_order
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.total_count = total_count
 | 
				
			||||||
 | 
					        self.total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.table.setColumnCount(9)
 | 
				
			||||||
 | 
					        columns = ["", "Image", "SKU", "Product Name", "Account", "Listed At", "Condition", "Status", "Actions"]
 | 
				
			||||||
 | 
					        self.table.setHorizontalHeaderLabels(columns)
 | 
				
			||||||
 | 
					        self.table.setRowCount(len(page_items))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def handler(item, i_row):
 | 
				
			||||||
 | 
					            listed_id = item.get("id")
 | 
				
			||||||
 | 
					            # Checkbox
 | 
				
			||||||
 | 
					            cb = QCheckBox()
 | 
				
			||||||
 | 
					            cb.setProperty("listed_id", listed_id)
 | 
				
			||||||
 | 
					            self.table.setCellWidget(i_row, 0, cb)
 | 
				
			||||||
 | 
					            cb.stateChanged.connect(self.update_options_menu)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Image
 | 
				
			||||||
 | 
					            images = json.loads(item.get("images") or "[]")
 | 
				
			||||||
 | 
					            if images:
 | 
				
			||||||
 | 
					                pixmap = ImageService.get_display_pixmap(images[0], size=60)
 | 
				
			||||||
 | 
					                if pixmap:
 | 
				
			||||||
 | 
					                    lbl = QLabel()
 | 
				
			||||||
 | 
					                    lbl.setPixmap(pixmap)
 | 
				
			||||||
 | 
					                    lbl.setAlignment(Qt.AlignCenter)
 | 
				
			||||||
 | 
					                    self.table.setCellWidget(i_row, 1, lbl)
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    self.table.setItem(i_row, 1, QTableWidgetItem("None"))
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.table.setItem(i_row, 1, QTableWidgetItem("None"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # SKU, Product Name, Account
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 2, QTableWidgetItem(item.get("sku") or ""))
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 3, QTableWidgetItem(item.get("product_name") or ""))
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 4, QTableWidgetItem(item.get("account_email") or ""))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Listed At
 | 
				
			||||||
 | 
					            listed_str = ""
 | 
				
			||||||
 | 
					            ts = item.get("listed_at")
 | 
				
			||||||
 | 
					            if ts:
 | 
				
			||||||
 | 
					                from datetime import datetime
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    listed_str = datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M")
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    listed_str = str(ts)
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 5, QTableWidgetItem(listed_str))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Condition, Status
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 6, QTableWidgetItem(item.get("condition") or ""))
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 7, QTableWidgetItem(item.get("status") or "pending"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Actions
 | 
				
			||||||
 | 
					            btn_menu = QPushButton("Actions")
 | 
				
			||||||
 | 
					            menu = QMenu()
 | 
				
			||||||
 | 
					            act_del = QAction("Delete", btn_menu)
 | 
				
			||||||
 | 
					            act_del.triggered.connect(partial(self.delete_listed, listed_id))
 | 
				
			||||||
 | 
					            menu.addAction(act_del)
 | 
				
			||||||
 | 
					            btn_menu.setMenu(menu)
 | 
				
			||||||
 | 
					            self.table.setCellWidget(i_row, 8, btn_menu)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        items_with_index = [(p, i) for i, p in enumerate(page_items)]
 | 
				
			||||||
 | 
					        if show_progress:
 | 
				
			||||||
 | 
					            run_with_progress(items_with_index, handler=lambda x: handler(*x), message="Loading listed...", parent=self)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            for item in items_with_index:
 | 
				
			||||||
 | 
					                handler(*item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Header sizing
 | 
				
			||||||
 | 
					        header = self.table.horizontalHeader()
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(1, QHeaderView.Fixed)
 | 
				
			||||||
 | 
					        self.table.setColumnWidth(1, 60)
 | 
				
			||||||
 | 
					        for idx in range(2, 8):
 | 
				
			||||||
 | 
					            header.setSectionResizeMode(idx, QHeaderView.Stretch)
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(8, QHeaderView.Fixed)
 | 
				
			||||||
 | 
					        self.table.setColumnWidth(8, 100)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Pagination
 | 
				
			||||||
 | 
					        self.prev_btn.setEnabled(self.current_page > 0)
 | 
				
			||||||
 | 
					        self.next_btn.setEnabled(self.current_page < self.total_pages - 1)
 | 
				
			||||||
 | 
					        self.page_info_label.setText(f"Page {self.current_page + 1} / {self.total_pages} ({self.total_count} items)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Reset header checkbox
 | 
				
			||||||
 | 
					        if isinstance(header, CheckBoxHeader):
 | 
				
			||||||
 | 
					            header.isOn = False
 | 
				
			||||||
 | 
					            header.viewport().update()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.update_options_menu()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Options Menu ---
 | 
				
			||||||
 | 
					    def update_options_menu(self):
 | 
				
			||||||
 | 
					        menu = QMenu()
 | 
				
			||||||
 | 
					        # Reload
 | 
				
			||||||
 | 
					        action_reload = QAction("Reload", menu)
 | 
				
			||||||
 | 
					        action_reload.triggered.connect(lambda: self.load_data(show_progress=True))
 | 
				
			||||||
 | 
					        menu.addAction(action_reload)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Filter
 | 
				
			||||||
 | 
					        action_filter = QAction("Filter", menu)
 | 
				
			||||||
 | 
					        action_filter.triggered.connect(self.open_filter_dialog)
 | 
				
			||||||
 | 
					        menu.addAction(action_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Clear Filter
 | 
				
			||||||
 | 
					        if self.filters:
 | 
				
			||||||
 | 
					            action_clear = QAction("Clear Filter", menu)
 | 
				
			||||||
 | 
					            action_clear.triggered.connect(self.clear_filters)
 | 
				
			||||||
 | 
					            menu.addAction(action_clear)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Delete Selected
 | 
				
			||||||
 | 
					        if any(isinstance(self.table.cellWidget(i, 0), QCheckBox) and self.table.cellWidget(i, 0).isChecked()
 | 
				
			||||||
 | 
					               for i in range(self.table.rowCount())):
 | 
				
			||||||
 | 
					            action_delete_selected = QAction("Delete Selected", menu)
 | 
				
			||||||
 | 
					            action_delete_selected.triggered.connect(self.delete_selected)
 | 
				
			||||||
 | 
					            menu.addAction(action_delete_selected)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.options_btn.setMenu(menu)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Filter ---
 | 
				
			||||||
 | 
					    def open_filter_dialog(self):
 | 
				
			||||||
 | 
					        dialog = ListedFilterDialog(self)
 | 
				
			||||||
 | 
					        if dialog.exec_():
 | 
				
			||||||
 | 
					            self.filters = dialog.get_filters()
 | 
				
			||||||
 | 
					            self.current_page = 0
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def clear_filters(self):
 | 
				
			||||||
 | 
					        self.filters = {}
 | 
				
			||||||
 | 
					        self.current_page = 0
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Pagination ---
 | 
				
			||||||
 | 
					    def go_to_page(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            page = int(self.page_input.text()) - 1
 | 
				
			||||||
 | 
					            if 0 <= page < self.total_pages:
 | 
				
			||||||
 | 
					                self.current_page = page
 | 
				
			||||||
 | 
					                self.load_data()
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def next_page(self):
 | 
				
			||||||
 | 
					        if self.current_page < self.total_pages - 1:
 | 
				
			||||||
 | 
					            self.current_page += 1
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def prev_page(self):
 | 
				
			||||||
 | 
					        if self.current_page > 0:
 | 
				
			||||||
 | 
					            self.current_page -= 1
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Select All ---
 | 
				
			||||||
 | 
					    def select_all_rows(self, checked):
 | 
				
			||||||
 | 
					        for i in range(self.table.rowCount()):
 | 
				
			||||||
 | 
					            cb = self.table.cellWidget(i, 0)
 | 
				
			||||||
 | 
					            if isinstance(cb, QCheckBox):
 | 
				
			||||||
 | 
					                cb.setChecked(checked)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Delete ---
 | 
				
			||||||
 | 
					    def delete_selected(self):
 | 
				
			||||||
 | 
					        ids = [int(cb.property("listed_id")) for i in range(self.table.rowCount())
 | 
				
			||||||
 | 
					               if isinstance(cb := self.table.cellWidget(i, 0), QCheckBox) and cb.isChecked()]
 | 
				
			||||||
 | 
					        if not ids:
 | 
				
			||||||
 | 
					            QMessageBox.information(self, "Info", "No listed selected")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        confirm = QMessageBox.question(
 | 
				
			||||||
 | 
					            self, "Confirm Delete", f"Delete {len(ids)} selected listed items?",
 | 
				
			||||||
 | 
					            QMessageBox.Yes | QMessageBox.No
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if confirm != QMessageBox.Yes:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        run_with_progress(ids, handler=lambda x: Listed.bulk_delete([x]), message="Deleting listed...", parent=self)
 | 
				
			||||||
 | 
					        self.current_page = 0
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete_listed(self, listed_id):
 | 
				
			||||||
 | 
					        confirm = QMessageBox.question(
 | 
				
			||||||
 | 
					            self, "Confirm Delete", f"Delete listed ID {listed_id}?", QMessageBox.Yes | QMessageBox.No
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if confirm != QMessageBox.Yes:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        run_with_progress([listed_id], handler=lambda x: Listed.bulk_delete([x]), message="Deleting listed...", parent=self)
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,64 @@
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import QDialog, QVBoxLayout, QComboBox, QPushButton, QMessageBox
 | 
				
			||||||
 | 
					from services.core.loading_service import run_with_progress
 | 
				
			||||||
 | 
					from database.models.listed import Listed
 | 
				
			||||||
 | 
					from database.models.account import Account  # import model
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AddListedDialog(QDialog):
 | 
				
			||||||
 | 
					    def __init__(self, product_ids, parent=None):
 | 
				
			||||||
 | 
					        super().__init__(parent)
 | 
				
			||||||
 | 
					        self.setWindowTitle("Add Listed")
 | 
				
			||||||
 | 
					        self.product_ids = product_ids
 | 
				
			||||||
 | 
					        self.selected_account = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout = QVBoxLayout()
 | 
				
			||||||
 | 
					        self.combo = QComboBox()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Lấy account trực tiếp ---
 | 
				
			||||||
 | 
					        accounts = Account.all()  # trả về list tuple từ DB
 | 
				
			||||||
 | 
					        for acc in accounts:
 | 
				
			||||||
 | 
					            acc_id = acc[0]
 | 
				
			||||||
 | 
					            acc_email = acc[1]
 | 
				
			||||||
 | 
					            acc_name = acc[2] if len(acc) > 2 else acc_email
 | 
				
			||||||
 | 
					            display_text = f"{acc_email} ({acc_name})"
 | 
				
			||||||
 | 
					            self.combo.addItem(display_text, acc_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout.addWidget(self.combo)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        btn_ok = QPushButton("Add Listed")
 | 
				
			||||||
 | 
					        btn_ok.clicked.connect(self.process_add_listed)
 | 
				
			||||||
 | 
					        layout.addWidget(btn_ok)
 | 
				
			||||||
 | 
					        self.setLayout(layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def process_add_listed(self):
 | 
				
			||||||
 | 
					        self.selected_account = self.combo.currentData()
 | 
				
			||||||
 | 
					        if not self.selected_account:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Warning", "No account selected")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.product_ids:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Warning", "No products selected")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        confirm = QMessageBox.question(
 | 
				
			||||||
 | 
					            self, "Confirm Add Listed",
 | 
				
			||||||
 | 
					            f"Add {len(self.product_ids)} product(s) to listed under selected account?",
 | 
				
			||||||
 | 
					            QMessageBox.Yes | QMessageBox.No
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if confirm != QMessageBox.Yes:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def handler(product_id):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                Listed.bulk_create([{"product_id": product_id, "account_id": self.selected_account}])
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                print(f"Error adding listed for product {product_id}: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        run_with_progress(
 | 
				
			||||||
 | 
					            self.product_ids,
 | 
				
			||||||
 | 
					            handler=handler,
 | 
				
			||||||
 | 
					            message="Adding listed...",
 | 
				
			||||||
 | 
					            parent=self
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        QMessageBox.information(self, "Success", f"Added {len(self.product_ids)} product(s) to listed.")
 | 
				
			||||||
 | 
					        self.accept()
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,117 @@
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import (
 | 
				
			||||||
 | 
					    QDialog, QFormLayout, QLineEdit, QDateEdit, QComboBox,
 | 
				
			||||||
 | 
					    QDialogButtonBox, QPushButton, QHBoxLayout, QVBoxLayout, QWidget
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from PyQt5.QtCore import QDate
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FilterDialog(QDialog):
 | 
				
			||||||
 | 
					    SAMPLE_CATEGORIES = ["Electronics", "Clothing", "Shoes", "Accessories", "Home"]
 | 
				
			||||||
 | 
					    SAMPLE_CONDITIONS = ["New", "Used", "Refurbished"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, parent=None):
 | 
				
			||||||
 | 
					        super().__init__(parent)
 | 
				
			||||||
 | 
					        self.setWindowTitle("Filter Products")
 | 
				
			||||||
 | 
					        self.setMinimumSize(500, 300)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        main_layout = QVBoxLayout()
 | 
				
			||||||
 | 
					        columns_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Column 1 (Basic filters) ---
 | 
				
			||||||
 | 
					        left_widget = QWidget()
 | 
				
			||||||
 | 
					        left_form = QFormLayout(left_widget)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.name_input = QLineEdit()
 | 
				
			||||||
 | 
					        left_form.addRow("Name:", self.name_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.price_input = QLineEdit()
 | 
				
			||||||
 | 
					        left_form.addRow("Price:", self.price_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.created_input = QDateEdit()
 | 
				
			||||||
 | 
					        self.created_input.setCalendarPopup(True)
 | 
				
			||||||
 | 
					        self.created_input.setDisplayFormat("yyyy-MM-dd")
 | 
				
			||||||
 | 
					        self.created_input.setDate(QDate.currentDate())
 | 
				
			||||||
 | 
					        left_form.addRow("Created at:", self.created_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.category_input = QComboBox()
 | 
				
			||||||
 | 
					        self.category_input.addItem("Any")
 | 
				
			||||||
 | 
					        self.category_input.addItems(self.SAMPLE_CATEGORIES)
 | 
				
			||||||
 | 
					        left_form.addRow("Category:", self.category_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        columns_layout.addWidget(left_widget)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Column 2 (Advanced filters) ---
 | 
				
			||||||
 | 
					        right_widget = QWidget()
 | 
				
			||||||
 | 
					        right_form = QFormLayout(right_widget)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.condition_input = QComboBox()
 | 
				
			||||||
 | 
					        self.condition_input.addItem("Any")
 | 
				
			||||||
 | 
					        self.condition_input.addItems(self.SAMPLE_CONDITIONS)
 | 
				
			||||||
 | 
					        right_form.addRow("Condition:", self.condition_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.brand_input = QLineEdit()
 | 
				
			||||||
 | 
					        right_form.addRow("Brand:", self.brand_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.tags_input = QLineEdit()
 | 
				
			||||||
 | 
					        right_form.addRow("Tags (comma separated):", self.tags_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.sku_input = QLineEdit()
 | 
				
			||||||
 | 
					        right_form.addRow("SKU:", self.sku_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.location_input = QLineEdit()
 | 
				
			||||||
 | 
					        right_form.addRow("Location:", self.location_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        columns_layout.addWidget(right_widget)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        main_layout.addLayout(columns_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Button Box ---
 | 
				
			||||||
 | 
					        self.btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
 | 
				
			||||||
 | 
					        self.btn_box.accepted.connect(self.accept)
 | 
				
			||||||
 | 
					        self.btn_box.rejected.connect(self.reject)
 | 
				
			||||||
 | 
					        main_layout.addWidget(self.btn_box)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.setLayout(main_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_filters(self):
 | 
				
			||||||
 | 
					        filters = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        name = self.name_input.text().strip()
 | 
				
			||||||
 | 
					        if name:
 | 
				
			||||||
 | 
					            filters["name"] = name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        price_text = self.price_input.text().strip()
 | 
				
			||||||
 | 
					        if price_text:
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                filters["price"] = float(price_text)
 | 
				
			||||||
 | 
					            except ValueError:
 | 
				
			||||||
 | 
					                pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.created_input.date().isValid():
 | 
				
			||||||
 | 
					            filters["created_at"] = self.created_input.date().toString("yyyy-MM-dd")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        category = self.category_input.currentText()
 | 
				
			||||||
 | 
					        if category != "Any":
 | 
				
			||||||
 | 
					            filters["category"] = category
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        condition = self.condition_input.currentText()
 | 
				
			||||||
 | 
					        if condition != "Any":
 | 
				
			||||||
 | 
					            filters["condition"] = condition
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        brand = self.brand_input.text().strip()
 | 
				
			||||||
 | 
					        if brand:
 | 
				
			||||||
 | 
					            filters["brand"] = brand
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        tags_text = self.tags_input.text().strip()
 | 
				
			||||||
 | 
					        if tags_text:
 | 
				
			||||||
 | 
					            filters["tags"] = [t.strip() for t in tags_text.split(",") if t.strip()]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        sku = self.sku_input.text().strip()
 | 
				
			||||||
 | 
					        if sku:
 | 
				
			||||||
 | 
					            filters["sku"] = sku
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        location = self.location_input.text().strip()
 | 
				
			||||||
 | 
					        if location:
 | 
				
			||||||
 | 
					            filters["location"] = location
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return filters
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,166 @@
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import (
 | 
				
			||||||
 | 
					    QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
 | 
				
			||||||
 | 
					    QComboBox, QTextEdit, QPushButton, QGridLayout,
 | 
				
			||||||
 | 
					    QFileDialog, QMessageBox
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from PyQt5.QtCore import Qt
 | 
				
			||||||
 | 
					from database.models.product import Product
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ProductForm(QDialog):
 | 
				
			||||||
 | 
					    SAMPLE_NAMES = [
 | 
				
			||||||
 | 
					        "Apple iPhone 15", "Samsung Galaxy S23", "Sony Headphones",
 | 
				
			||||||
 | 
					        "Dell Laptop", "Canon Camera", "Nike Shoes", "Adidas T-Shirt"
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    SAMPLE_CATEGORIES = ["Electronics", "Clothing", "Shoes", "Accessories", "Home"]
 | 
				
			||||||
 | 
					    SAMPLE_CONDITIONS = ["New", "Used", "Refurbished"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, parent=None, product=None):
 | 
				
			||||||
 | 
					        super().__init__(parent)
 | 
				
			||||||
 | 
					        self.setWindowTitle("Product Form")
 | 
				
			||||||
 | 
					        self.product = product
 | 
				
			||||||
 | 
					        self.setMinimumSize(600, 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        main_layout = QVBoxLayout()
 | 
				
			||||||
 | 
					        grid = QGridLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Column 1
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Name"), 0, 0)
 | 
				
			||||||
 | 
					        self.name_input = QLineEdit()
 | 
				
			||||||
 | 
					        self.name_input.setPlaceholderText(", ".join(self.SAMPLE_NAMES))
 | 
				
			||||||
 | 
					        grid.addWidget(self.name_input, 0, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Price"), 1, 0)
 | 
				
			||||||
 | 
					        self.price_input = QLineEdit()
 | 
				
			||||||
 | 
					        grid.addWidget(self.price_input, 1, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Category"), 2, 0)
 | 
				
			||||||
 | 
					        self.category_input = QComboBox()
 | 
				
			||||||
 | 
					        self.category_input.addItem("None")
 | 
				
			||||||
 | 
					        self.category_input.addItems(self.SAMPLE_CATEGORIES)
 | 
				
			||||||
 | 
					        grid.addWidget(self.category_input, 2, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Condition"), 3, 0)
 | 
				
			||||||
 | 
					        self.condition_input = QComboBox()
 | 
				
			||||||
 | 
					        self.condition_input.addItem("None")
 | 
				
			||||||
 | 
					        self.condition_input.addItems(self.SAMPLE_CONDITIONS)
 | 
				
			||||||
 | 
					        grid.addWidget(self.condition_input, 3, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Brand"), 4, 0)
 | 
				
			||||||
 | 
					        self.brand_input = QLineEdit()
 | 
				
			||||||
 | 
					        grid.addWidget(self.brand_input, 4, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Column 2
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Description"), 0, 2)
 | 
				
			||||||
 | 
					        self.description_input = QTextEdit()
 | 
				
			||||||
 | 
					        self.description_input.setFixedHeight(80)
 | 
				
			||||||
 | 
					        grid.addWidget(self.description_input, 0, 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Tags (comma separated)"), 1, 2)
 | 
				
			||||||
 | 
					        self.tags_input = QLineEdit()
 | 
				
			||||||
 | 
					        grid.addWidget(self.tags_input, 1, 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("SKU"), 2, 2)
 | 
				
			||||||
 | 
					        self.sku_input = QLineEdit()
 | 
				
			||||||
 | 
					        grid.addWidget(self.sku_input, 2, 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Location"), 3, 2)
 | 
				
			||||||
 | 
					        self.location_input = QLineEdit()
 | 
				
			||||||
 | 
					        grid.addWidget(self.location_input, 3, 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        grid.addWidget(QLabel("Images (comma separated URLs or files)"), 4, 2)
 | 
				
			||||||
 | 
					        self.images_input = QTextEdit()
 | 
				
			||||||
 | 
					        self.images_input.setFixedHeight(60)
 | 
				
			||||||
 | 
					        grid.addWidget(self.images_input, 4, 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.select_file_btn = QPushButton("Select Image File(s)")
 | 
				
			||||||
 | 
					        self.select_file_btn.clicked.connect(self.select_files)
 | 
				
			||||||
 | 
					        grid.addWidget(self.select_file_btn, 5, 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        main_layout.addLayout(grid)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Buttons
 | 
				
			||||||
 | 
					        btn_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.save_btn = QPushButton("Save")
 | 
				
			||||||
 | 
					        self.save_btn.clicked.connect(self.save)
 | 
				
			||||||
 | 
					        btn_layout.addWidget(self.save_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.cancel_btn = QPushButton("Cancel")
 | 
				
			||||||
 | 
					        self.cancel_btn.clicked.connect(self.close)
 | 
				
			||||||
 | 
					        btn_layout.addWidget(self.cancel_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        main_layout.addLayout(btn_layout)
 | 
				
			||||||
 | 
					        self.setLayout(main_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Nếu đang edit product
 | 
				
			||||||
 | 
					        if product:
 | 
				
			||||||
 | 
					            self.load_product(product)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def load_product(self, product):
 | 
				
			||||||
 | 
					        self.name_input.setText(product.get("name", ""))
 | 
				
			||||||
 | 
					        self.price_input.setText(str(product.get("price", 0)))
 | 
				
			||||||
 | 
					        self.category_input.setCurrentText(product.get("category") or "None")
 | 
				
			||||||
 | 
					        self.condition_input.setCurrentText(product.get("condition") or "None")
 | 
				
			||||||
 | 
					        self.brand_input.setText(product.get("brand") or "")
 | 
				
			||||||
 | 
					        self.description_input.setPlainText(product.get("description") or "")
 | 
				
			||||||
 | 
					        tags = json.loads(product.get("tags") or "[]")
 | 
				
			||||||
 | 
					        self.tags_input.setText(",".join(tags))
 | 
				
			||||||
 | 
					        self.sku_input.setText(product.get("sku") or "")
 | 
				
			||||||
 | 
					        self.location_input.setText(product.get("location") or "")
 | 
				
			||||||
 | 
					        images = json.loads(product.get("images") or "[]")
 | 
				
			||||||
 | 
					        self.images_input.setText(",".join(images))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def select_files(self):
 | 
				
			||||||
 | 
					        files, _ = QFileDialog.getOpenFileNames(
 | 
				
			||||||
 | 
					            self, "Select Image Files", "", "Images (*.png *.jpg *.jpeg *.bmp)"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if files:
 | 
				
			||||||
 | 
					            existing = [img.strip() for img in self.images_input.toPlainText().split(",") if img.strip()]
 | 
				
			||||||
 | 
					            self.images_input.setText(",".join(existing + files))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self):
 | 
				
			||||||
 | 
					        name = self.name_input.text().strip()
 | 
				
			||||||
 | 
					        if not name:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Error", "Name cannot be empty")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            price = float(self.price_input.text().strip())
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Error", "Price must be a valid number")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        category = self.category_input.currentText() if self.category_input.currentText() != "None" else None
 | 
				
			||||||
 | 
					        condition = self.condition_input.currentText() if self.condition_input.currentText() != "None" else None
 | 
				
			||||||
 | 
					        brand = self.brand_input.text().strip() or None
 | 
				
			||||||
 | 
					        description = self.description_input.toPlainText().strip() or None
 | 
				
			||||||
 | 
					        tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()] or None
 | 
				
			||||||
 | 
					        sku = self.sku_input.text().strip() or None
 | 
				
			||||||
 | 
					        location = self.location_input.text().strip() or None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Xử lý images: remove dấu ngoặc và quote nếu copy từ JSON
 | 
				
			||||||
 | 
					        raw_text = self.images_input.toPlainText()
 | 
				
			||||||
 | 
					        raw_text = raw_text.replace('[', '').replace(']', '').replace('"', '').replace("'", '')
 | 
				
			||||||
 | 
					        images = [img.strip() for img in raw_text.split(",") if img.strip()]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if self.product and "id" in self.product:
 | 
				
			||||||
 | 
					                Product.update(
 | 
				
			||||||
 | 
					                    self.product["id"], name, price,
 | 
				
			||||||
 | 
					                    images=images,
 | 
				
			||||||
 | 
					                    url=None, status="draft",
 | 
				
			||||||
 | 
					                    category=category, condition=condition, brand=brand,
 | 
				
			||||||
 | 
					                    description=description, tags=tags, sku=sku, location=location
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                Product.create(
 | 
				
			||||||
 | 
					                    name, price,
 | 
				
			||||||
 | 
					                    images=images,
 | 
				
			||||||
 | 
					                    url=None, status="draft",
 | 
				
			||||||
 | 
					                    category=category, condition=condition, brand=brand,
 | 
				
			||||||
 | 
					                    description=description, tags=tags, sku=sku, location=location
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            self.accept()
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Error", str(e))
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,416 @@
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					from functools import partial
 | 
				
			||||||
 | 
					from services.core.loading_service import run_with_progress
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import (
 | 
				
			||||||
 | 
					    QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
 | 
				
			||||||
 | 
					    QPushButton, QHBoxLayout, QMenu, QAction, QHeaderView,
 | 
				
			||||||
 | 
					    QLabel, QCheckBox, QSizePolicy, QMessageBox, QStyleOptionButton, QStyle, QStylePainter, QLineEdit
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from PyQt5.QtCore import Qt, QRect, pyqtSignal
 | 
				
			||||||
 | 
					from database.models.product import Product
 | 
				
			||||||
 | 
					from database.models.listed import Listed
 | 
				
			||||||
 | 
					from services.image_service import ImageService
 | 
				
			||||||
 | 
					from gui.tabs.products.forms.product_form import ProductForm
 | 
				
			||||||
 | 
					from gui.tabs.products.dialogs.product_filter_dialog import FilterDialog
 | 
				
			||||||
 | 
					from gui.tabs.products.dialogs.add_listed_dialog import AddListedDialog
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					PAGE_SIZE = 10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- Header Checkbox ---
 | 
				
			||||||
 | 
					class CheckBoxHeader(QHeaderView):
 | 
				
			||||||
 | 
					    select_all_changed = pyqtSignal(bool)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, orientation, parent=None):
 | 
				
			||||||
 | 
					        super().__init__(orientation, parent)
 | 
				
			||||||
 | 
					        self.isOn = False
 | 
				
			||||||
 | 
					        self.setSectionsClickable(True)
 | 
				
			||||||
 | 
					        self.sectionPressed.connect(self.handle_section_pressed)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def paintSection(self, painter, rect, logicalIndex):
 | 
				
			||||||
 | 
					        super().paintSection(painter, rect, logicalIndex)
 | 
				
			||||||
 | 
					        if logicalIndex == 0:
 | 
				
			||||||
 | 
					            option = QStyleOptionButton()
 | 
				
			||||||
 | 
					            size = 20
 | 
				
			||||||
 | 
					            x = rect.x() + (rect.width() - size) // 2
 | 
				
			||||||
 | 
					            y = rect.y() + (rect.height() - size) // 2
 | 
				
			||||||
 | 
					            option.rect = QRect(x, y, size, size)
 | 
				
			||||||
 | 
					            option.state = QStyle.State_Enabled | (QStyle.State_On if self.isOn else QStyle.State_Off)
 | 
				
			||||||
 | 
					            painter2 = QStylePainter(self.viewport())
 | 
				
			||||||
 | 
					            painter2.drawControl(QStyle.CE_CheckBox, option)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_section_pressed(self, logicalIndex):
 | 
				
			||||||
 | 
					        if logicalIndex == 0:
 | 
				
			||||||
 | 
					            self.isOn = not self.isOn
 | 
				
			||||||
 | 
					            self.select_all_changed.emit(self.isOn)
 | 
				
			||||||
 | 
					            self.viewport().update()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# --- ProductTab ---
 | 
				
			||||||
 | 
					class ProductTab(QWidget):
 | 
				
			||||||
 | 
					    SORTABLE_COLUMNS = {
 | 
				
			||||||
 | 
					        1: "id",
 | 
				
			||||||
 | 
					        3: "name",
 | 
				
			||||||
 | 
					        4: "price",
 | 
				
			||||||
 | 
					        7: "created_at"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					        self.current_page = 0
 | 
				
			||||||
 | 
					        self.total_count = 0
 | 
				
			||||||
 | 
					        self.total_pages = 0
 | 
				
			||||||
 | 
					        self.filters = {}
 | 
				
			||||||
 | 
					        self.sort_by = "id"
 | 
				
			||||||
 | 
					        self.sort_order = "ASC"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout = QVBoxLayout()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Top menu
 | 
				
			||||||
 | 
					        top_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.add_btn = QPushButton("Add Product")
 | 
				
			||||||
 | 
					        self.add_btn.clicked.connect(self.add_product)
 | 
				
			||||||
 | 
					        top_layout.addWidget(self.add_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.options_btn = QPushButton("Action")
 | 
				
			||||||
 | 
					        self.options_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
 | 
				
			||||||
 | 
					        self.options_btn.setMinimumWidth(50)
 | 
				
			||||||
 | 
					        self.options_btn.setMaximumWidth(120)
 | 
				
			||||||
 | 
					        top_layout.addWidget(self.options_btn)
 | 
				
			||||||
 | 
					        layout.addLayout(top_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Table
 | 
				
			||||||
 | 
					        self.table = QTableWidget()
 | 
				
			||||||
 | 
					        self.table.verticalHeader().setDefaultSectionSize(60)
 | 
				
			||||||
 | 
					        self.table.setEditTriggers(QTableWidget.NoEditTriggers)
 | 
				
			||||||
 | 
					        self.table.setSelectionBehavior(QTableWidget.SelectRows)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        header = CheckBoxHeader(Qt.Horizontal, self.table)
 | 
				
			||||||
 | 
					        self.table.setHorizontalHeader(header)
 | 
				
			||||||
 | 
					        header.select_all_changed.connect(self.select_all_rows)
 | 
				
			||||||
 | 
					        header.sectionClicked.connect(self.handle_header_click)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        layout.addWidget(self.table)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Pagination
 | 
				
			||||||
 | 
					        pag_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.prev_btn = QPushButton("Previous")
 | 
				
			||||||
 | 
					        self.prev_btn.clicked.connect(self.prev_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.prev_btn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.page_info_label = QLabel("Page 1 / 1 (0 items)")
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.page_info_label)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.page_input = QLineEdit()
 | 
				
			||||||
 | 
					        self.page_input.setFixedWidth(50)
 | 
				
			||||||
 | 
					        self.page_input.setPlaceholderText("Page")
 | 
				
			||||||
 | 
					        self.page_input.returnPressed.connect(self.go_to_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.page_input)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.next_btn = QPushButton("Next")
 | 
				
			||||||
 | 
					        self.next_btn.clicked.connect(self.next_page)
 | 
				
			||||||
 | 
					        pag_layout.addWidget(self.next_btn)
 | 
				
			||||||
 | 
					        layout.addLayout(pag_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.setLayout(layout)
 | 
				
			||||||
 | 
					        # self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Handle Header Click for Sorting ---
 | 
				
			||||||
 | 
					    def handle_header_click(self, logicalIndex):
 | 
				
			||||||
 | 
					        if logicalIndex in self.SORTABLE_COLUMNS:
 | 
				
			||||||
 | 
					            col = self.SORTABLE_COLUMNS[logicalIndex]
 | 
				
			||||||
 | 
					            if self.sort_by == col:
 | 
				
			||||||
 | 
					                self.sort_order = "DESC" if self.sort_order == "ASC" else "ASC"
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.sort_by = col
 | 
				
			||||||
 | 
					                self.sort_order = "ASC"
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Options Menu ---
 | 
				
			||||||
 | 
					    def update_options_menu(self):
 | 
				
			||||||
 | 
					        menu = QMenu()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        action_reload = QAction("Reload", menu)
 | 
				
			||||||
 | 
					        action_reload.triggered.connect(lambda: self.load_data(show_progress=True))
 | 
				
			||||||
 | 
					        menu.addAction(action_reload)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        action_filter = QAction("Filter", menu)
 | 
				
			||||||
 | 
					        action_filter.triggered.connect(self.open_filter_dialog)
 | 
				
			||||||
 | 
					        menu.addAction(action_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.filters:
 | 
				
			||||||
 | 
					            action_clear = QAction("Clear Filter", menu)
 | 
				
			||||||
 | 
					            action_clear.triggered.connect(self.clear_filters)
 | 
				
			||||||
 | 
					            menu.addAction(action_clear)
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        # --- Thêm Add Listed Selected ---
 | 
				
			||||||
 | 
					        if any(isinstance(self.table.cellWidget(i, 0), QCheckBox) and self.table.cellWidget(i, 0).isChecked()
 | 
				
			||||||
 | 
					            for i in range(self.table.rowCount())):
 | 
				
			||||||
 | 
					            action_add_listed_selected = QAction("Add Listed Selected", menu)
 | 
				
			||||||
 | 
					            action_add_listed_selected.triggered.connect(self.add_listed_selected)
 | 
				
			||||||
 | 
					            menu.addAction(action_add_listed_selected)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if any(isinstance(self.table.cellWidget(i, 0), QCheckBox) and self.table.cellWidget(i, 0).isChecked()
 | 
				
			||||||
 | 
					               for i in range(self.table.rowCount())):
 | 
				
			||||||
 | 
					            action_delete_selected = QAction("Delete Selected", menu)
 | 
				
			||||||
 | 
					            action_delete_selected.triggered.connect(self.delete_selected)
 | 
				
			||||||
 | 
					            menu.addAction(action_delete_selected)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.options_btn.setMenu(menu)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Filter ---
 | 
				
			||||||
 | 
					    def open_filter_dialog(self):
 | 
				
			||||||
 | 
					        dialog = FilterDialog(self)
 | 
				
			||||||
 | 
					        if dialog.exec_():
 | 
				
			||||||
 | 
					            self.filters = dialog.get_filters()
 | 
				
			||||||
 | 
					            self.current_page = 0
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def clear_filters(self):
 | 
				
			||||||
 | 
					        self.filters = {}
 | 
				
			||||||
 | 
					        self.current_page = 0
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Load Data ---
 | 
				
			||||||
 | 
					    def load_data(self, show_progress=True):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Load products vào table.
 | 
				
			||||||
 | 
					        - show_progress: nếu True thì hiển thị progress dialog (user trigger), False khi mở app.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.table.clearContents()
 | 
				
			||||||
 | 
					        self.table.setRowCount(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        offset = self.current_page * PAGE_SIZE
 | 
				
			||||||
 | 
					        # Lấy toàn bộ dữ liệu cần load
 | 
				
			||||||
 | 
					        page_items, total_count = Product.get_paginated(
 | 
				
			||||||
 | 
					            offset, PAGE_SIZE, self.filters,
 | 
				
			||||||
 | 
					            sort_by=self.sort_by, sort_order=self.sort_order
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.total_count = total_count
 | 
				
			||||||
 | 
					        self.total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.table.setColumnCount(9)
 | 
				
			||||||
 | 
					        columns = ["", "Image", "SKU", "Name", "Price", "Condition", "Brand", "Created At", "Actions"]
 | 
				
			||||||
 | 
					        self.table.setHorizontalHeaderLabels(columns)
 | 
				
			||||||
 | 
					        self.table.setRowCount(len(page_items))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def handler(p, i_row):
 | 
				
			||||||
 | 
					            product_id = p.get("id")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Checkbox
 | 
				
			||||||
 | 
					            cb = QCheckBox()
 | 
				
			||||||
 | 
					            cb.setProperty("product_id", product_id)
 | 
				
			||||||
 | 
					            self.table.setCellWidget(i_row, 0, cb)
 | 
				
			||||||
 | 
					            cb.stateChanged.connect(self.update_options_menu)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Image
 | 
				
			||||||
 | 
					            images = json.loads(p.get("images") or "[]")
 | 
				
			||||||
 | 
					            if images:
 | 
				
			||||||
 | 
					                pixmap = ImageService.get_display_pixmap(images[0], size=60)
 | 
				
			||||||
 | 
					                if pixmap:
 | 
				
			||||||
 | 
					                    lbl = QLabel()
 | 
				
			||||||
 | 
					                    lbl.setPixmap(pixmap)
 | 
				
			||||||
 | 
					                    lbl.setAlignment(Qt.AlignCenter)
 | 
				
			||||||
 | 
					                    self.table.setCellWidget(i_row, 1, lbl)
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    self.table.setItem(i_row, 1, QTableWidgetItem("None"))
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.table.setItem(i_row, 1, QTableWidgetItem("None"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Các cột text
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 2, QTableWidgetItem(p.get("sku") or ""))
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 3, QTableWidgetItem(p.get("name") or ""))
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 4, QTableWidgetItem(str(p.get("price") or "")))
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 5, QTableWidgetItem(p.get("condition") or ""))
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 6, QTableWidgetItem(p.get("brand") or ""))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            created_str = ""
 | 
				
			||||||
 | 
					            created_ts = p.get("created_at")
 | 
				
			||||||
 | 
					            if created_ts:
 | 
				
			||||||
 | 
					                from datetime import datetime
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    created_str = datetime.fromtimestamp(int(created_ts)).strftime("%Y-%m-%d %H:%M")
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    created_str = str(created_ts)
 | 
				
			||||||
 | 
					            self.table.setItem(i_row, 7, QTableWidgetItem(created_str))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Actions
 | 
				
			||||||
 | 
					            btn_menu = QPushButton("Actions")
 | 
				
			||||||
 | 
					            menu = QMenu()
 | 
				
			||||||
 | 
					            act_edit = QAction("Edit", btn_menu)
 | 
				
			||||||
 | 
					            act_edit.triggered.connect(partial(self.edit_product, p))
 | 
				
			||||||
 | 
					            menu.addAction(act_edit)
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            act_add_listed = QAction("Add Listed", btn_menu)  # <-- thêm action này
 | 
				
			||||||
 | 
					            act_add_listed.triggered.connect(partial(self.add_listed_row, product_id))
 | 
				
			||||||
 | 
					            menu.addAction(act_add_listed)
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            act_del = QAction("Delete", btn_menu)
 | 
				
			||||||
 | 
					            act_del.triggered.connect(partial(self.delete_product, product_id))
 | 
				
			||||||
 | 
					            menu.addAction(act_del)
 | 
				
			||||||
 | 
					            btn_menu.setMenu(menu)
 | 
				
			||||||
 | 
					            self.table.setCellWidget(i_row, 8, btn_menu)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Hiển thị progress nếu cần ---
 | 
				
			||||||
 | 
					        items_with_index = [(p, i) for i, p in enumerate(page_items)]
 | 
				
			||||||
 | 
					        if show_progress:
 | 
				
			||||||
 | 
					            run_with_progress(
 | 
				
			||||||
 | 
					                items_with_index,
 | 
				
			||||||
 | 
					                handler=lambda x: handler(*x),
 | 
				
			||||||
 | 
					                message="Loading products...",
 | 
				
			||||||
 | 
					                parent=self
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            for item in items_with_index:
 | 
				
			||||||
 | 
					                handler(*item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Header sizing
 | 
				
			||||||
 | 
					        header = self.table.horizontalHeader()
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(1, QHeaderView.Fixed)
 | 
				
			||||||
 | 
					        self.table.setColumnWidth(1, 60)
 | 
				
			||||||
 | 
					        for idx in range(2, 8):
 | 
				
			||||||
 | 
					            header.setSectionResizeMode(idx, QHeaderView.Stretch)
 | 
				
			||||||
 | 
					        header.setSectionResizeMode(8, QHeaderView.Fixed)
 | 
				
			||||||
 | 
					        self.table.setColumnWidth(8, 100)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Pagination
 | 
				
			||||||
 | 
					        self.prev_btn.setEnabled(self.current_page > 0)
 | 
				
			||||||
 | 
					        self.next_btn.setEnabled(self.current_page < self.total_pages - 1)
 | 
				
			||||||
 | 
					        self.page_info_label.setText(f"Page {self.current_page + 1} / {self.total_pages} ({self.total_count} items)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Reset header checkbox
 | 
				
			||||||
 | 
					        if isinstance(header, CheckBoxHeader):
 | 
				
			||||||
 | 
					            header.isOn = False
 | 
				
			||||||
 | 
					            header.viewport().update()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.update_options_menu()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Go to page ---
 | 
				
			||||||
 | 
					    def go_to_page(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            page = int(self.page_input.text()) - 1
 | 
				
			||||||
 | 
					            if 0 <= page < self.total_pages:
 | 
				
			||||||
 | 
					                self.current_page = page
 | 
				
			||||||
 | 
					                self.load_data()
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Select All ---
 | 
				
			||||||
 | 
					    def select_all_rows(self, checked):
 | 
				
			||||||
 | 
					        for i in range(self.table.rowCount()):
 | 
				
			||||||
 | 
					            cb = self.table.cellWidget(i, 0)
 | 
				
			||||||
 | 
					            if isinstance(cb, QCheckBox):
 | 
				
			||||||
 | 
					                cb.setChecked(checked)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Delete Selected ---
 | 
				
			||||||
 | 
					    def delete_selected(self):
 | 
				
			||||||
 | 
					        ids = [
 | 
				
			||||||
 | 
					            int(cb.property("product_id"))
 | 
				
			||||||
 | 
					            for i in range(self.table.rowCount())
 | 
				
			||||||
 | 
					            if isinstance(cb := self.table.cellWidget(i, 0), QCheckBox) and cb.isChecked()
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        if not ids:
 | 
				
			||||||
 | 
					            QMessageBox.information(self, "Info", "No product selected")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        confirm = QMessageBox.question(
 | 
				
			||||||
 | 
					            self, "Confirm Delete", f"Delete {len(ids)} selected products?",
 | 
				
			||||||
 | 
					            QMessageBox.Yes | QMessageBox.No
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if confirm != QMessageBox.Yes:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- dùng run_with_progress ---
 | 
				
			||||||
 | 
					        run_with_progress(
 | 
				
			||||||
 | 
					            ids,
 | 
				
			||||||
 | 
					            handler=Product.delete,
 | 
				
			||||||
 | 
					            message="Deleting products...",
 | 
				
			||||||
 | 
					            parent=self
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.current_page = 0
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Product Actions ---
 | 
				
			||||||
 | 
					    def add_product(self):
 | 
				
			||||||
 | 
					        form = ProductForm(self)
 | 
				
			||||||
 | 
					        if form.exec_():
 | 
				
			||||||
 | 
					            self.current_page = 0
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def edit_product(self, product):
 | 
				
			||||||
 | 
					        form = ProductForm(self, product)
 | 
				
			||||||
 | 
					        if form.exec_():
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete_product(self, product_id):
 | 
				
			||||||
 | 
					        confirm = QMessageBox.question(
 | 
				
			||||||
 | 
					            self, "Confirm Delete", f"Delete product ID {product_id}?", QMessageBox.Yes | QMessageBox.No
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if confirm != QMessageBox.Yes:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        run_with_progress(
 | 
				
			||||||
 | 
					            [product_id],
 | 
				
			||||||
 | 
					            handler=Product.delete,
 | 
				
			||||||
 | 
					            message="Deleting product...",
 | 
				
			||||||
 | 
					            parent=self
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.load_data()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def add_listed_selected(self):
 | 
				
			||||||
 | 
					        selected_ids = [
 | 
				
			||||||
 | 
					            int(cb.property("product_id"))
 | 
				
			||||||
 | 
					            for i in range(self.table.rowCount())
 | 
				
			||||||
 | 
					            if isinstance(cb := self.table.cellWidget(i, 0), QCheckBox) and cb.isChecked()
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        if not selected_ids:
 | 
				
			||||||
 | 
					            QMessageBox.information(self, "Info", "No products selected")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        dialog = AddListedDialog(selected_ids, parent=self)
 | 
				
			||||||
 | 
					        dialog.exec_()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Clear row checkboxes ---
 | 
				
			||||||
 | 
					        for i in range(self.table.rowCount()):
 | 
				
			||||||
 | 
					            cb = self.table.cellWidget(i, 0)
 | 
				
			||||||
 | 
					            if isinstance(cb, QCheckBox):
 | 
				
			||||||
 | 
					                cb.setChecked(False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Reset header checkbox ---
 | 
				
			||||||
 | 
					        self.reset_header_checkbox()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- Update menu ---
 | 
				
			||||||
 | 
					        self.update_options_menu()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def reset_header_checkbox(self):
 | 
				
			||||||
 | 
					        header = self.table.horizontalHeader()
 | 
				
			||||||
 | 
					        if isinstance(header, CheckBoxHeader):
 | 
				
			||||||
 | 
					            header.isOn = False
 | 
				
			||||||
 | 
					            header.viewport().update()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Add Listed for single row (row action) ---
 | 
				
			||||||
 | 
					    def add_listed_row(self, product_id):
 | 
				
			||||||
 | 
					        if not product_id:
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, "Warning", "Invalid product")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        dialog = AddListedDialog([product_id], parent=self)
 | 
				
			||||||
 | 
					        dialog.exec_()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Pagination ---
 | 
				
			||||||
 | 
					    def next_page(self):
 | 
				
			||||||
 | 
					        if self.current_page < self.total_pages - 1:
 | 
				
			||||||
 | 
					            self.current_page += 1
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def prev_page(self):
 | 
				
			||||||
 | 
					        if self.current_page > 0:
 | 
				
			||||||
 | 
					            self.current_page -= 1
 | 
				
			||||||
 | 
					            self.load_data()
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					PyQt5==5.15.11
 | 
				
			||||||
 | 
					PyQtWebEngine==5.15.6
 | 
				
			||||||
 | 
					opencv-python==4.10.0.84
 | 
				
			||||||
 | 
					numpy==1.26.4
 | 
				
			||||||
 | 
					SQLAlchemy==2.0.22
 | 
				
			||||||
 | 
					requests
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					sku,name,price,condition,brand,images
 | 
				
			||||||
 | 
					SKU001,Apple iPhone 13,18000000,New,Apple,https://example.com/iphone13.jpg
 | 
				
			||||||
 | 
					SKU002,Samsung Galaxy S21,15000000,Like New,Samsung,https://example.com/galaxys21.jpg
 | 
				
			||||||
 | 
					SKU003,Xiaomi Redmi Note 11,6000000,New,Xiaomi,https://example.com/redmi11.jpg
 | 
				
			||||||
 | 
					SKU004,Oppo Reno 6,8500000,New,Oppo,https://example.com/reno6.jpg
 | 
				
			||||||
 | 
					SKU005,Vivo V21,7000000,Used,Vivo,https://example.com/vivo21.jpg
 | 
				
			||||||
 | 
					SKU006,MacBook Air M1,23000000,Like New,Apple,https://example.com/macbookairm1.jpg
 | 
				
			||||||
 | 
					SKU007,Asus Zenbook 14,20000000,New,Asus,https://example.com/zenbook14.jpg
 | 
				
			||||||
 | 
					SKU008,Dell XPS 13,25000000,New,Dell,https://example.com/xps13.jpg
 | 
				
			||||||
 | 
					SKU009,HP Spectre x360,24000000,Like New,HP,https://example.com/spectre.jpg
 | 
				
			||||||
 | 
					SKU010,Lenovo ThinkPad X1,22000000,Used,Lenovo,https://example.com/thinkpadx1.jpg
 | 
				
			||||||
 | 
					SKU011,AirPods Pro,5500000,New,Apple,https://example.com/airpodspro.jpg
 | 
				
			||||||
 | 
					SKU012,Samsung Buds 2,3500000,New,Samsung,https://example.com/buds2.jpg
 | 
				
			||||||
 | 
					SKU013,Apple Watch SE,7500000,Like New,Apple,https://example.com/watchse.jpg
 | 
				
			||||||
 | 
					SKU014,Gaming Mouse Logitech G502,1800000,New,Logitech,https://example.com/g502.jpg
 | 
				
			||||||
 | 
					SKU015,Mechanical Keyboard Keychron K2,2500000,New,Keychron,https://example.com/keychronk2.jpg
 | 
				
			||||||
 | 
					SKU016,Sony WH-1000XM4,8500000,New,Sony,https://example.com/xm4.jpg
 | 
				
			||||||
 | 
					SKU017,Bose QuietComfort 45,9500000,Like New,Bose,https://example.com/qc45.jpg
 | 
				
			||||||
 | 
					SKU018,GoPro Hero 10,12000000,New,GoPro,https://example.com/gopro10.jpg
 | 
				
			||||||
 | 
					SKU019,Nikon D5600,15000000,Used,Nikon,https://example.com/d5600.jpg
 | 
				
			||||||
 | 
					SKU020,Canon EOS M50,16000000,Like New,Canon,https://example.com/m50.jpg
 | 
				
			||||||
		
		
			
  | 
| 
						 | 
					@ -0,0 +1,39 @@
 | 
				
			||||||
 | 
					from PyQt5.QtWidgets import QProgressDialog
 | 
				
			||||||
 | 
					from PyQt5.QtCore import Qt, QCoreApplication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def run_with_progress(items, handler, message="Loading...", cancel_text="Cancel", parent=None):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Run the handler for each item in `items` and display a loading progress bar.
 | 
				
			||||||
 | 
					    - items: iterable (list, tuple, ...)
 | 
				
			||||||
 | 
					    - handler: function to process each item (may raise errors, won't crash the app)
 | 
				
			||||||
 | 
					    - message: message displayed on the dialog
 | 
				
			||||||
 | 
					    - cancel_text: cancel button text
 | 
				
			||||||
 | 
					    - parent: optional parent QWidget
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    total = len(items)
 | 
				
			||||||
 | 
					    if total == 0:
 | 
				
			||||||
 | 
					        return 0, 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    progress = QProgressDialog(message, cancel_text, 0, total, parent)
 | 
				
			||||||
 | 
					    progress.setWindowModality(Qt.WindowModal)
 | 
				
			||||||
 | 
					    progress.setMinimumDuration(0)
 | 
				
			||||||
 | 
					    progress.setValue(0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    success_count = 0
 | 
				
			||||||
 | 
					    fail_count = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for i, item in enumerate(items):
 | 
				
			||||||
 | 
					        if progress.wasCanceled():
 | 
				
			||||||
 | 
					            break
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            handler(item)
 | 
				
			||||||
 | 
					            success_count += 1
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            print(f"Error processing item {i}: {e}")
 | 
				
			||||||
 | 
					            fail_count += 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        progress.setValue(i + 1)
 | 
				
			||||||
 | 
					        QCoreApplication.processEvents()  # Prevent UI freezing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    progress.close()
 | 
				
			||||||
 | 
					    return success_count, fail_count
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,41 @@
 | 
				
			||||||
 | 
					# services/image_service.py
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import requests
 | 
				
			||||||
 | 
					from PyQt5.QtGui import QPixmap
 | 
				
			||||||
 | 
					from PyQt5.QtCore import Qt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ImageService:
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_display_pixmap(image_path_or_url, size=60):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Load image from local path or URL and return QPixmap scaled.
 | 
				
			||||||
 | 
					        Returns None if cannot load.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        pixmap = QPixmap()
 | 
				
			||||||
 | 
					        if not image_path_or_url:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Local file
 | 
				
			||||||
 | 
					        if os.path.exists(image_path_or_url):
 | 
				
			||||||
 | 
					            pixmap.load(image_path_or_url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # URL
 | 
				
			||||||
 | 
					        elif image_path_or_url.startswith("http"):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
 | 
				
			||||||
 | 
					                resp = requests.get(image_path_or_url, headers=headers)
 | 
				
			||||||
 | 
					                if resp.status_code == 200:
 | 
				
			||||||
 | 
					                    pixmap.loadFromData(resp.content)
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    return None
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                print("Failed to load image from URL:", e)
 | 
				
			||||||
 | 
					                return None
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not pixmap.isNull():
 | 
				
			||||||
 | 
					            pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
 | 
				
			||||||
 | 
					            return pixmap
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
		Loading…
	
		Reference in New Issue