From ccd85a03fc39a1ffcb67629c11502364941937be Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 11 Oct 2025 11:32:07 +0700 Subject: [PATCH] first commit --- .gitignore | 49 +++ README.md | 0 app.py | 14 + config.py | 0 database/db.py | 61 +++ database/models/__init__.py | 2 + database/models/account.py | 41 ++ database/models/listed.py | 209 +++++++++ database/models/product.py | 208 +++++++++ facebook_marketplace.db | Bin 0 -> 40960 bytes gui/main_window.py | 42 ++ gui/tabs/account_tab.py | 173 ++++++++ gui/tabs/import_tab.py | 122 +++++ gui/tabs/listeds/forms/filter_listed_form.py | 44 ++ gui/tabs/listeds/listed_tab.py | 303 +++++++++++++ .../products/dialogs/add_listed_dialog.py | 64 +++ .../products/dialogs/product_filter_dialog.py | 117 +++++ gui/tabs/products/forms/product_form.py | 166 +++++++ gui/tabs/products/product_tab.py | 416 ++++++++++++++++++ gui/widgets/table_widget.py | 0 requirements.txt | 7 + sample_products.csv | 21 + services/core/loading_service.py | 39 ++ services/image_service.py | 41 ++ services/ui/account_service.py | 0 services/ui/product_service.py | 0 utils/helpers.py | 0 27 files changed, 2139 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 config.py create mode 100644 database/db.py create mode 100644 database/models/__init__.py create mode 100644 database/models/account.py create mode 100644 database/models/listed.py create mode 100644 database/models/product.py create mode 100644 facebook_marketplace.db create mode 100644 gui/main_window.py create mode 100644 gui/tabs/account_tab.py create mode 100644 gui/tabs/import_tab.py create mode 100644 gui/tabs/listeds/forms/filter_listed_form.py create mode 100644 gui/tabs/listeds/listed_tab.py create mode 100644 gui/tabs/products/dialogs/add_listed_dialog.py create mode 100644 gui/tabs/products/dialogs/product_filter_dialog.py create mode 100644 gui/tabs/products/forms/product_form.py create mode 100644 gui/tabs/products/product_tab.py create mode 100644 gui/widgets/table_widget.py create mode 100644 requirements.txt create mode 100644 sample_products.csv create mode 100644 services/core/loading_service.py create mode 100644 services/image_service.py create mode 100644 services/ui/account_service.py create mode 100644 services/ui/product_service.py create mode 100644 utils/helpers.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7cfea8 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py new file mode 100644 index 0000000..6c8374a --- /dev/null +++ b/app.py @@ -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() diff --git a/config.py b/config.py new file mode 100644 index 0000000..e69de29 diff --git a/database/db.py b/database/db.py new file mode 100644 index 0000000..a4c876b --- /dev/null +++ b/database/db.py @@ -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() diff --git a/database/models/__init__.py b/database/models/__init__.py new file mode 100644 index 0000000..a1a57d8 --- /dev/null +++ b/database/models/__init__.py @@ -0,0 +1,2 @@ +from .account import Account +from .product import Product diff --git a/database/models/account.py b/database/models/account.py new file mode 100644 index 0000000..49b4618 --- /dev/null +++ b/database/models/account.py @@ -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() diff --git a/database/models/listed.py b/database/models/listed.py new file mode 100644 index 0000000..f2f8dfb --- /dev/null +++ b/database/models/listed.py @@ -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 diff --git a/database/models/product.py b/database/models/product.py new file mode 100644 index 0000000..7bbba30 --- /dev/null +++ b/database/models/product.py @@ -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 + + diff --git a/facebook_marketplace.db b/facebook_marketplace.db new file mode 100644 index 0000000000000000000000000000000000000000..2d54abc1bf8b1f03c785e48dcc634c03e3663036 GIT binary patch literal 40960 zcmeI5TWlLy8OO(slQe1KlWSc!b$6OjHBn-FW_$-#*iGFwZ0feTNR;l%jK|Y>s6FF) z?1om$qNKapeOext2d)wbQ1<~<*$2cbJa7?Oc&LCUcDbxX(IU25UVyTIc;U=_JWiIgy#?FXP#I320hRA+~4=^c29wJAOHk_01yBIKmZ5; zft@CBv)t9~_4!;cMQd!n#FqrQ#uv1|J+lj$^iqagN}rg|kXjo#*-P3sp+L@Mmold_ z3*`BQxwGkoi{#Upi)4Cv>D*ivKmBYbyEN1A43^`hx>(EEo|!$jL}r)g=NosdRHZ_l zYhg#NV>z6$>Ro9(j$C7Fb(vhsd~%6AnK_kSo?jx5Rd}%=h%3itNHP$x?vh36?&kM7 z@|bqaV+}oD&dyy}&P-Yc%#caW;K2v;E<0uW3J=-n`A>OhcxoySuy}_qhl`EbyO|uaN+w1qcUglMC7_VWJoecspT0gWzM7GSUUnH&kuR8>6pagIc7@lJ)7TTP%Cp!@wv`-* zA6=2E*BeFGEOAc4Dy#{T_@Ev0RaPu)VPt`qxvEex57F4o8oT1qz1juceX;IKZ*J7d z!kZg+fACd-EZp7vrno|WVi9Xmu2r#jG{BIr)U{q}ZqWEjB?+6nr5)xmrb3-v-dX>4 z!ZXB^qm5eE`Hn8{^t9`uDm`7PGJmzsiyZH`>uwODm6D(9PH>M;!u zG|D{UzgL-8`th#|y+`01Y(M}A00AHX1b_e#00KY&2mk>f00e+Qn-kdY>Lez7 zJ#1=4S(*rPQkiDL_@=zU??Qhi@C`N~00e*l5C8%|00;m9AOHk_01yBIK;TIs;OUsK zuL3CV|97C@5a@5{Pq=^$2mk>f00e*l5C8%|00;m9AOHk_01#*=0=*p_K9}}CJ%{)0 z>G1a$AN}uhb@*Mze_W8A*d`bHk78N3Rf00e*l5C8%| z;K2m;dX>-rkD^WjeGc76@1ftLU!k9(H_&&{H_#W+%cv7wMLfEQ&Z0DmqABD@gAYC` z>;VLT01yBIKmZ5;0U!VbfB+Bx0zhC_64=)<=30DunWAX=DYMQ{BW9gW4qNrakX4Tl zTJ_j~RgWU89`RW9aKBY&`g-wO^l4d^IgYfNsl#TSO8TvO;*eF3k6HEDL8~71S@p;P zs~+}Rb!NX+r$?2~|M$4xCOn_-f3+{y`-`40b#HdP+W9@)1RD?l0zjZG39NmJ93Z?d z!n>YvPYio~gpXL|i~Oory#(;3@zAo&SLF~}C<|ifq_irQB(@-j=9KIIl9S=7;AblQ z%J>ZVfE^>P?f9UhX_p-iR>YO@XN&h<_h(8xSF1{*z{y#Dbyko$iOq9((`JjcOHU0D zp>D#vcHHe9_VE*A-bJ=tDe>fa?F!l?O{cEE^JTn!MYp$Dt5xKWhC=mPkqtS5fYT@O5Fzk|>Arp?o-WqQr7nDuQ?=w;;(!XTwL+$=rfamR8vujV)69 zx^ZW*DDg@!Pbe2GPA?S&nZ!4il(in&uUCw}WyZLEHtu0?9AB-NnZbYPaeekZ!-|ry z{*l9)b?eJ+Keo=>Y#o&QQ8>QjXSd?saC`nSvFwJ zwhoI5ju)kxj)_t+-Mr)F(BkeM}II`){!ilYQM@Z;F13QB)a8QX^7GF@kDBbjoY1CfmbdRdkCLBHyPo4W` z@A1mF-f9yV#jJzb5!JW_Wg20{c{x>qk@N z-@S!Z{O!ldOm$boHm68hw=o(VSHjzjq7-uqqoPKbd;WmwusD;!`fyHSk<^VpKcmcI zkJDD0K6dxS(~xYn9yTL-*u;aN3oW7#K!2Z~=p#Nx>_G>^{^3J*D}hfRm3ZDJ4G17dW< zOm6YK&Nt8N43&EBb)2+Ke$YnLZr@Us=?oLC&m|1baFsIwH5XEXg{D)6(r5Pd(6GsE zV${d`n#4vYQ|!x1wma2U+-#?Mk03Z&%OmyagHGC%ww50S z+Rg{)n9UDv_9D$zd`>WdmlKXOOC*}Qeot9VSa8_7Yb9dKS9ac4(;WV?C0VKp*0N6P z=^l;qJ#{q9T#t_024j8NWBRKW#+~^d9Z5aE)_&8U4ceoGaV1@9xUF@dK z*)bhXoo=s5@Bby;M^8m)oW-dUHnR{qtPjXqsn3j?%5*c`97~u~X-HXL=yk-Bo%hqN zrih1dRK~K>9y%GDx8mIWiT|4J^`raEvlu)*aLudDOzQe~+ASb$+_43utiCwkx;U^l zOz~5Uw%oils&T^0&Dzph(|Zo7BZ}^iUv((I9cIy8+edX3W-CvBh-6T8bXr}3vGPHR zR)}`j%wqbkQnASFAZ#$^94cgm$ zTHAoV?SR&X>}_7H&0}xdueJ5t+eWpvK3kje|Np&clR*E*-vIDFdKbNeevN*KevW>O zHc`F#=gAvMum}hM0U!VbfB+Bx0zd!=00AHX1c1P9CeY>Xak)khtHp?4Ert)N#n6~q Y3?5XA0iRl+18U*%szv{PwdfoDFBFBe8~^|S literal 0 HcmV?d00001 diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..faa4b8e --- /dev/null +++ b/gui/main_window.py @@ -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 diff --git a/gui/tabs/account_tab.py b/gui/tabs/account_tab.py new file mode 100644 index 0000000..01cfb1e --- /dev/null +++ b/gui/tabs/account_tab.py @@ -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() diff --git a/gui/tabs/import_tab.py b/gui/tabs/import_tab.py new file mode 100644 index 0000000..ee06723 --- /dev/null +++ b/gui/tabs/import_tab.py @@ -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) diff --git a/gui/tabs/listeds/forms/filter_listed_form.py b/gui/tabs/listeds/forms/filter_listed_form.py new file mode 100644 index 0000000..029fd2f --- /dev/null +++ b/gui/tabs/listeds/forms/filter_listed_form.py @@ -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 diff --git a/gui/tabs/listeds/listed_tab.py b/gui/tabs/listeds/listed_tab.py new file mode 100644 index 0000000..6adf0bc --- /dev/null +++ b/gui/tabs/listeds/listed_tab.py @@ -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() diff --git a/gui/tabs/products/dialogs/add_listed_dialog.py b/gui/tabs/products/dialogs/add_listed_dialog.py new file mode 100644 index 0000000..552267f --- /dev/null +++ b/gui/tabs/products/dialogs/add_listed_dialog.py @@ -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() diff --git a/gui/tabs/products/dialogs/product_filter_dialog.py b/gui/tabs/products/dialogs/product_filter_dialog.py new file mode 100644 index 0000000..e53e2d9 --- /dev/null +++ b/gui/tabs/products/dialogs/product_filter_dialog.py @@ -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 diff --git a/gui/tabs/products/forms/product_form.py b/gui/tabs/products/forms/product_form.py new file mode 100644 index 0000000..eb39e2a --- /dev/null +++ b/gui/tabs/products/forms/product_form.py @@ -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)) diff --git a/gui/tabs/products/product_tab.py b/gui/tabs/products/product_tab.py new file mode 100644 index 0000000..e32d88b --- /dev/null +++ b/gui/tabs/products/product_tab.py @@ -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() diff --git a/gui/widgets/table_widget.py b/gui/widgets/table_widget.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8c8e0d0 --- /dev/null +++ b/requirements.txt @@ -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 + diff --git a/sample_products.csv b/sample_products.csv new file mode 100644 index 0000000..1467f5d --- /dev/null +++ b/sample_products.csv @@ -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 diff --git a/services/core/loading_service.py b/services/core/loading_service.py new file mode 100644 index 0000000..783bc72 --- /dev/null +++ b/services/core/loading_service.py @@ -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 diff --git a/services/image_service.py b/services/image_service.py new file mode 100644 index 0000000..3a4b3a7 --- /dev/null +++ b/services/image_service.py @@ -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 diff --git a/services/ui/account_service.py b/services/ui/account_service.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ui/product_service.py b/services/ui/product_service.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000..e69de29