From 38cf8c2cfc4d51e12d7ebbbf5d97a9c773d09668 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Sun, 17 Aug 2025 21:31:29 +0700 Subject: [PATCH] first commit --- README.md | 121 ++ __pycache__/test_export.cpython-312.pyc | Bin 0 -> 4927 bytes main.py | 29 + manufacturing.db | Bin 0 -> 86016 bytes requirements.txt | 13 + src/__init__.py | 0 src/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 153 bytes src/__pycache__/app.cpython-312.pyc | Bin 0 -> 11759 bytes src/__pycache__/auth.cpython-312.pyc | Bin 0 -> 17606 bytes src/__pycache__/dao.cpython-312.pyc | Bin 0 -> 47836 bytes src/__pycache__/dao_items.cpython-312.pyc | Bin 0 -> 15962 bytes src/__pycache__/dao_stock.cpython-312.pyc | Bin 0 -> 8235 bytes src/__pycache__/database.cpython-312.pyc | Bin 0 -> 14090 bytes src/__pycache__/models.cpython-312.pyc | Bin 0 -> 10766 bytes src/__pycache__/services.cpython-312.pyc | Bin 0 -> 36776 bytes src/app.py | 232 ++++ src/auth.py | 357 +++++ src/dao.py | 901 ++++++++++++ src/dao_items.py | 295 ++++ src/dao_stock.py | 156 +++ src/database.py | 325 +++++ src/models.py | 240 ++++ src/services.py | 699 ++++++++++ src/ui/__init__.py | 0 src/ui/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 156 bytes .../customer_dialog.cpython-312.pyc | Bin 0 -> 6702 bytes .../date_picker_dialog.cpython-312.pyc | Bin 0 -> 8293 bytes .../date_range_dialog.cpython-312.pyc | Bin 0 -> 10117 bytes .../__pycache__/main_window.cpython-312.pyc | Bin 0 -> 70712 bytes ...manufacturing_order_dialog.cpython-312.pyc | Bin 0 -> 7452 bytes .../product_dialog.cpython-312.pyc | Bin 0 -> 5281 bytes .../purchase_order_dialog.cpython-312.pyc | Bin 0 -> 15211 bytes .../sales_order_dialog.cpython-312.pyc | Bin 0 -> 15128 bytes .../supplier_dialog.cpython-312.pyc | Bin 0 -> 6702 bytes .../user_management_dialog.cpython-312.pyc | Bin 0 -> 27751 bytes src/ui/customer_dialog.py | 108 ++ src/ui/date_picker_dialog.py | 148 ++ src/ui/date_range_dialog.py | 145 ++ src/ui/main_window.py | 1218 +++++++++++++++++ src/ui/manufacturing_order_dialog.py | 107 ++ src/ui/product_dialog.py | 83 ++ src/ui/purchase_order_dialog.py | 233 ++++ src/ui/sales_order_dialog.py | 233 ++++ src/ui/supplier_dialog.py | 108 ++ src/ui/user_management_dialog.py | 460 +++++++ test_export.py | 119 ++ test_ui_and_exports.py | 107 ++ 47 files changed, 6437 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/test_export.cpython-312.pyc create mode 100644 main.py create mode 100644 manufacturing.db create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-312.pyc create mode 100644 src/__pycache__/app.cpython-312.pyc create mode 100644 src/__pycache__/auth.cpython-312.pyc create mode 100644 src/__pycache__/dao.cpython-312.pyc create mode 100644 src/__pycache__/dao_items.cpython-312.pyc create mode 100644 src/__pycache__/dao_stock.cpython-312.pyc create mode 100644 src/__pycache__/database.cpython-312.pyc create mode 100644 src/__pycache__/models.cpython-312.pyc create mode 100644 src/__pycache__/services.cpython-312.pyc create mode 100644 src/app.py create mode 100644 src/auth.py create mode 100644 src/dao.py create mode 100644 src/dao_items.py create mode 100644 src/dao_stock.py create mode 100644 src/database.py create mode 100644 src/models.py create mode 100644 src/services.py create mode 100644 src/ui/__init__.py create mode 100644 src/ui/__pycache__/__init__.cpython-312.pyc create mode 100644 src/ui/__pycache__/customer_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/date_picker_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/date_range_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/main_window.cpython-312.pyc create mode 100644 src/ui/__pycache__/manufacturing_order_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/product_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/purchase_order_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/sales_order_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/supplier_dialog.cpython-312.pyc create mode 100644 src/ui/__pycache__/user_management_dialog.cpython-312.pyc create mode 100644 src/ui/customer_dialog.py create mode 100644 src/ui/date_picker_dialog.py create mode 100644 src/ui/date_range_dialog.py create mode 100644 src/ui/main_window.py create mode 100644 src/ui/manufacturing_order_dialog.py create mode 100644 src/ui/product_dialog.py create mode 100644 src/ui/purchase_order_dialog.py create mode 100644 src/ui/sales_order_dialog.py create mode 100644 src/ui/supplier_dialog.py create mode 100644 src/ui/user_management_dialog.py create mode 100644 test_export.py create mode 100644 test_ui_and_exports.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..d106907 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Mini Basic Manufacture App + +A simple manufacturing application for small businesses, designed for ease of use by users 50 years and older with a modern UI. + +## Features + +- **User Authentication & Permissions**: Secure login system with role-based access control +- **Database Management**: Supports both SQLite (default) and PostgreSQL with backup/restore/duplicate functionality +- **Core Modules**: + - Purchase Management + - Basic Manufacturing (simple production tracking) + - Sales Management + - Inventory Tracking +- **User & Group Configuration**: Admin panel for managing users and permissions +- **Dashboard**: Overview of key business metrics +- **Excel Export**: Export all data to Excel format for external analysis +- **Modern UI**: Clean, modern interface using customtkinter +- **Cross-platform**: Works on Windows, macOS, and Linux + +## Requirements + +- Python 3.6 or higher +- SQLite (included with Python) +- Optional: PostgreSQL for production use + +## Installation + +1. Clone or download this repository +2. Install the required packages: + ``` + pip install -r requirements.txt + ``` +3. Run the application: + ``` + python main.py + ``` + +## First Run + +1. On first run, you'll be prompted to create an admin account +2. Log in with your admin credentials +3. Configure database settings if needed (SQLite is default) +4. Start using the application modules + +## Database Configuration + +Admin users can configure the database connection: +- **SQLite**: File-based database, no additional setup required +- **PostgreSQL**: For production environments, requires PostgreSQL server + +Database management features: +- Backup: Create a backup of the current database +- Restore: Restore from a previous backup +- Duplicate: Create a copy of the database + +## User Permissions + +The application supports: +- Admin users: Full access to all features +- Regular users: Access based on group permissions +- User groups: Assign permissions to groups of users + +## Modules + +### Purchase +- Create and manage purchase orders +- Track supplier information +- Monitor order status +- View purchase reports with monthly charts +- **Export**: Export purchase orders to Excel format + +### Manufacturing +- Create manufacturing orders for end products +- Track production progress +- Simple workflow without complex BOM +- **Export**: Export manufacturing orders to Excel format + +### Sales +- Create and manage sales orders +- Track customer information +- Monitor order status +- View sales reports with monthly charts +- **Export**: Export sales orders to Excel format + +### Inventory +- Track product quantities +- View stock levels +- Adjust inventory manually +- **Export**: Export inventory data and stock movements to Excel format + +## Dashboard + +The dashboard provides an overview of key business metrics: +- Quick stats on pending orders and low inventory +- Financial summary with revenue, costs, and profit +- Monthly sales and purchase charts +- Recent activities log + +## Excel Export Features + +All modules support Excel export functionality: +- **Purchase Orders**: Export all purchase order data including suppliers, items, and status +- **Manufacturing Orders**: Export production orders with product details and progress +- **Sales Orders**: Export sales data including customers, products, and order status +- **Inventory**: Export current stock levels and detailed stock movement history + +Exported files are saved with timestamps in the filename for easy organization. + +## Development + +This application is built with: +- Python 3 +- **customtkinter** for the modern GUI (replaced standard tkinter) +- SQLAlchemy for database operations +- Matplotlib for charting +- SQLite/PostgreSQL for data storage +- **pandas** and **openpyxl** for Excel export functionality + +## License + +This project is open source and available under the MIT License. \ No newline at end of file diff --git a/__pycache__/test_export.cpython-312.pyc b/__pycache__/test_export.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5e409c4c043d4dcdfa05aa488933102e68bda04 GIT binary patch literal 4927 zcmb7IU2GHC6~1GS;~D?W*oo~VKr%@PV8{<7Bq2Yn7D8Zwg=`5e#e`bMGXZ=3rj(AQh5u@N?XyF-aDS_v4gv! zBW3QrXTI~DbI(1Vb0*K7P8)!i^2O?nFblvx&;nnSK6yP$0Pq0tAP9JZH^qq{p^c`X z36AEtIbjZ(2_TqaSOYV_F=nq?=u@5$h`Lzk+t~ksWa=QPC(uuH@#GUzd4@*K0cfC*v2MdMp7B16Sz@X*{R&gd zs#xl3H&?XTD(x19-KO18SG%>M?XJ?^p}4ncTkC4?tY~|xw09|_t zCsKlHJ2WtOcxZU!$mqe02kS|CWvwXU$_f?3NujKGaP-K?@X+DGfkTF3M6bYO+{*#z zPNo|(MsXG8L?MzAd2TKxV)wXAGlr%0g3UpQAVL<@+GTd($BF97jWK$-;C3Zcy({7t ztsgO>UG6|66v-A8*AO-q(u_%c3vp>7(|Kk3>{Sjo4rMY)?_Qt@IR#_Vrm5FBQ^T=W zJ#}iBF*g@f_9BSYewoTHfoBMpq5?VscBXEm2FO!_Y51P$j6~X>ZXu@T}{oN zXV4?A@@SiSz;UVzQ5J}(-w_?~rn(~N z_aTJz^j67a7sDb3_UvEbE zvDx&Zd?S^NqS(2Z!LPGr@Eu7PY==tZzz+?;(@}JHE}wxje|I)m^c`HD-t_OuW{UoS0@+kEJ&-$7 z^p7Cv&Y^s)xbs+nY}st;&c=#OhnCN6Ht)-|7Ml+jNG~L_*b26e64|-c0GPIQSI?TO zC-1ENC)-wV9V}6YAe`manf^7VKhJ#K`ps;S z2`+!IVP!OMXZFaWqpL@={RP)Ri5mQW!S2p=c65y$eYW=x`+vXx>uW{!^z!KqE30u2 z<*$Dg`!bfFEx3-Cs7c6eYh9=N*XaH{^_Bfgdy$@4KE7e4HSoTi=c)IxH)pMQZEIhr zN7rbBn?7y8dvn*G3Xg@{rGjgyL=7Wu|2iF5qXW5pPY*mkP^5eFMnr$p}EA~}oqZ@xY2zTq|BaAD*^(KoYV*BlzikLP;}uJ=pSG05)kS@(^v`Nj(q zvqj%k9WwIFo?j}srb^UF1lhgr8(i}Z77l+{^qtco{ds#1LXMWG_aLM>uMHjkyIib7i3hW=OYn8 zlIG^)@x_kJ4jZlyGw{=$D}(34@Q2NJyhNYVfo9bj3h}8(C?vMQHGCB=N+S9&6p>$Q zLo^BBNU}=CQqiPp<)fnZa)JJ!w?inT{cl10SAtewR7!t;N$*f;wBI}08lr?$R^4~e zgDjKqbuB*zui+es<48hHB;da&;=+64hj59!mma{|mYE=kSEPv`UsHgveG6=V1-sx$ z66B}UC)B6RC(J$K-oS&A`y&q~?oVV*kF2ZKN6addr;6O@v!P<+u@X4`Pq6z(6G@nU z1n~9>-M)8RAqaLWV*;dOCG%%<%Z7_9x;nLY!Ujs1qc5oU{*ER+C93x?6uV-+OWmRF Qxj(mWIRV)!o`t^s7cW=ic>n+a literal 0 HcmV?d00001 diff --git a/main.py b/main.py new file mode 100644 index 0000000..00be942 --- /dev/null +++ b/main.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +""" +Mini Basic Manufacture App +A simple manufacturing application for small businesses +""" + +import customtkinter as ctk +from tkinter import messagebox +import sys +import os + +# Add the src directory to the path +sys.path.append(os.path.join(os.path.dirname(__file__), 'src')) + +from src.app import ManufacturingApp + + +def main(): + """Main entry point for the application""" + try: + app = ManufacturingApp() + app.run() + except Exception as e: + messagebox.showerror("Error", f"Failed to start application: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/manufacturing.db b/manufacturing.db new file mode 100644 index 0000000000000000000000000000000000000000..9c5ccdca423ab21b1dcc603504af1a45682d534f GIT binary patch literal 86016 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9d2f616 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# Core dependencies +sqlalchemy>=2.0.0 +bcrypt>=4.0.0 + +# UI dependencies +customtkinter>=5.2.0 + +# Excel export dependencies +openpyxl>=3.1.0 +pandas>=2.0.0 + +# Development dependencies (optional) +pytest>=7.0.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e21a3360985d5fdd9c9552103eab9a357ec02597 GIT binary patch literal 153 zcmX@j%ge<81S)l= literal 0 HcmV?d00001 diff --git a/src/__pycache__/app.cpython-312.pyc b/src/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ea15cd9d63d37382b034a4934b58e2941a78ae7 GIT binary patch literal 11759 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/auth.cpython-312.pyc b/src/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..824f85c259160e8ec25ea40e785355632b6cd682 GIT binary patch literal 17606 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 G_5U9NLgLf_ literal 0 HcmV?d00001 diff --git a/src/__pycache__/dao.cpython-312.pyc b/src/__pycache__/dao.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..19d492d05b0fdb336acaeefc3801f2f719449bed GIT binary patch literal 47836 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 P&tT^LzhH1pPUZgxmjsPT literal 0 HcmV?d00001 diff --git a/src/__pycache__/dao_items.cpython-312.pyc b/src/__pycache__/dao_items.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfce0357cd9d924e1f7961ddb7f9ddb62db2c28d GIT binary patch literal 15962 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 mC&T|`XQ(~@K_GhE@GTqVzjNp@!QGyz+V%+m literal 0 HcmV?d00001 diff --git a/src/__pycache__/dao_stock.cpython-312.pyc b/src/__pycache__/dao_stock.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..742fcbdedc2b74f78aa163822d51cad4f9afa0a8 GIT binary patch literal 8235 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/database.cpython-312.pyc b/src/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54d6e1d55e6c4fd8a0b20da1af1ae792997e7a44 GIT binary patch literal 14090 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 EzZC`toB#j- literal 0 HcmV?d00001 diff --git a/src/__pycache__/services.cpython-312.pyc b/src/__pycache__/services.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7127773f484183f395be2fa29dcf367644859670 GIT binary patch literal 36776 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 G(f", lambda e: self.login()) + + # Admin setup button (only shown if no users exist) + try: + if not self.auth_manager.has_users(): + setup_button = ctk.CTkButton(login_frame, text="Setup Admin Account", + command=self.show_admin_setup, + width=300, height=40, + fg_color="green", hover_color="dark green") + setup_button.pack(pady=(10, 20)) + except Exception as e: + messagebox.showerror("Error", f"Failed to check users: {str(e)}") + + def login(self): + """Handle login""" + username = self.username_entry.get() + password = self.password_entry.get() + + if not username or not password: + messagebox.showerror("Error", "Please enter both username and password") + return + + user = self.auth_manager.authenticate(username, password) + if user: + messagebox.showinfo("Success", f"Welcome, {user['username']}!") + self.show_main_window(user) + else: + messagebox.showerror("Error", "Invalid username or password") + + def show_admin_setup(self): + """Show admin account setup""" + # Clear any existing widgets + for widget in self.root.winfo_children(): + widget.destroy() + + # Create setup frame + setup_frame = ctk.CTkFrame(self.root) + setup_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Title + title_label = ctk.CTkLabel(setup_frame, text="Admin Account Setup", + font=("Arial", 24, "bold")) + title_label.pack(pady=(20, 30)) + + # Username + username_label = ctk.CTkLabel(setup_frame, text="Admin Username:", + font=("Arial", 14)) + username_label.pack(anchor="w", padx=20, pady=(0, 5)) + + self.admin_username_entry = ctk.CTkEntry(setup_frame, width=300, height=40, + font=("Arial", 14)) + self.admin_username_entry.pack(pady=(0, 15)) + self.admin_username_entry.focus() + + # Password + password_label = ctk.CTkLabel(setup_frame, text="Admin Password:", + font=("Arial", 14)) + password_label.pack(anchor="w", padx=20, pady=(0, 5)) + + self.admin_password_entry = ctk.CTkEntry(setup_frame, width=300, height=40, + font=("Arial", 14), show="*") + self.admin_password_entry.pack(pady=(0, 15)) + + # Confirm Password + confirm_label = ctk.CTkLabel(setup_frame, text="Confirm Password:", + font=("Arial", 14)) + confirm_label.pack(anchor="w", padx=20, pady=(0, 5)) + + self.admin_confirm_entry = ctk.CTkEntry(setup_frame, width=300, height=40, + font=("Arial", 14), show="*") + self.admin_confirm_entry.pack(pady=(0, 20)) + + # Setup button + setup_button = ctk.CTkButton(setup_frame, text="Create Admin Account", + command=self.create_admin, + width=300, height=40, font=("Arial", 14)) + setup_button.pack(pady=(0, 10)) + + # Back to login button + back_button = ctk.CTkButton(setup_frame, text="Back to Login", + command=self.show_login, + width=300, height=40, font=("Arial", 14), + fg_color="gray", hover_color="dark gray") + back_button.pack(pady=(10, 20)) + + def create_admin(self): + """Create admin account""" + username = self.admin_username_entry.get() + password = self.admin_password_entry.get() + confirm = self.admin_confirm_entry.get() + + if not username or not password: + messagebox.showerror("Error", "Please enter both username and password") + return + + if password != confirm: + messagebox.showerror("Error", "Passwords do not match") + return + + try: + self.auth_manager.create_user(username, password, is_admin=True) + messagebox.showinfo("Success", "Admin account created successfully!") + self.show_login() + except Exception as e: + messagebox.showerror("Error", f"Failed to create admin account: {str(e)}") + + def show_main_window(self, user): + """Show the main application window""" + # Clear any existing widgets + for widget in self.root.winfo_children(): + widget.destroy() + + # Resize window for main application + self.root.geometry("1200x800") + + # Create main window + self.main_window = MainWindow(self.root, user, self.db_manager, self) + + def restart(self): + """Restart the application""" + self.root.destroy() + self.__init__() + self.run() \ No newline at end of file diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..50df084 --- /dev/null +++ b/src/auth.py @@ -0,0 +1,357 @@ +import hashlib +import sqlite3 +import os + + +class AuthManager: + """Authentication manager for the manufacturing app""" + + def __init__(self, db_manager): + """Initialize the authentication manager""" + self.db_manager = db_manager + self.current_user = None + + def hash_password(self, password): + """Hash a password using SHA-256""" + return hashlib.sha256(password.encode()).hexdigest() + + def create_user(self, username, password, is_admin=False): + """Create a new user""" + if not self.db_manager.connect(): + raise Exception("Database connection failed") + + try: + cursor = self.db_manager.connection.cursor() + password_hash = self.hash_password(password) + + cursor.execute(''' + INSERT INTO users (username, password_hash, is_admin) + VALUES (?, ?, ?) + ''', (username, password_hash, is_admin)) + + self.db_manager.connection.commit() + return True + except sqlite3.IntegrityError: + raise Exception("Username already exists") + except Exception as e: + raise Exception(f"Failed to create user: {str(e)}") + finally: + self.db_manager.disconnect() + + def authenticate(self, username, password): + """Authenticate a user""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + password_hash = self.hash_password(password) + + cursor.execute(''' + SELECT id, username, is_admin FROM users + WHERE username = ? AND password_hash = ? + ''', (username, password_hash)) + + user = cursor.fetchone() + if user: + self.current_user = { + 'id': user[0], + 'username': user[1], + 'is_admin': user[2] + } + return self.current_user + return None + except Exception as e: + print(f"Authentication error: {e}") + return None + finally: + self.db_manager.disconnect() + + def has_users(self): + """Check if any users exist""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT COUNT(*) FROM users') + count = cursor.fetchone()[0] + return count > 0 + except Exception as e: + print(f"Error checking users: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_user_permissions(self, user_id): + """Get user permissions""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + + # Get user groups + cursor.execute(''' + SELECT g.permissions FROM user_groups g + JOIN user_group_members m ON g.id = m.group_id + WHERE m.user_id = ? + ''', (user_id,)) + + permissions = [] + for row in cursor.fetchall(): + if row[0]: + permissions.extend(row[0].split(',')) + + return permissions + except Exception as e: + print(f"Error getting user permissions: {e}") + return [] + finally: + self.db_manager.disconnect() + + def user_has_permission(self, user_id, permission): + """Check if user has a specific permission""" + permissions = self.get_user_permissions(user_id) + return permission in permissions or self.is_admin(user_id) + + def is_admin(self, user_id): + """Check if user is admin""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT is_admin FROM users WHERE id = ?', (user_id,)) + result = cursor.fetchone() + return result[0] if result else False + except Exception as e: + print(f"Error checking admin status: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_all_users(self): + """Get all users""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT id, username, is_admin FROM users') + users = [] + for row in cursor.fetchall(): + users.append({ + 'id': row[0], + 'username': row[1], + 'is_admin': row[2] + }) + return users + except Exception as e: + print(f"Error getting users: {e}") + return [] + finally: + self.db_manager.disconnect() + + def delete_user(self, user_id): + """Delete a user""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + + # Delete user group memberships first + cursor.execute('DELETE FROM user_group_members WHERE user_id = ?', (user_id,)) + + # Delete user + cursor.execute('DELETE FROM users WHERE id = ?', (user_id,)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting user: {e}") + return False + finally: + self.db_manager.disconnect() + + # Group management methods + def create_group(self, name, permissions=None): + """Create a new user group""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + permissions_str = ','.join(permissions) if permissions else None + + cursor.execute(''' + INSERT INTO user_groups (name, permissions) + VALUES (?, ?) + ''', (name, permissions_str)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating group: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_all_groups(self): + """Get all user groups""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT id, name, permissions FROM user_groups') + groups = [] + for row in cursor.fetchall(): + permissions = row[2].split(',') if row[2] else [] + groups.append({ + 'id': row[0], + 'name': row[1], + 'permissions': permissions + }) + return groups + except Exception as e: + print(f"Error getting groups: {e}") + return [] + finally: + self.db_manager.disconnect() + + def delete_group(self, group_id): + """Delete a user group""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + + # Delete group memberships first + cursor.execute('DELETE FROM user_group_members WHERE group_id = ?', (group_id,)) + + # Delete group + cursor.execute('DELETE FROM user_groups WHERE id = ?', (group_id,)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting group: {e}") + return False + finally: + self.db_manager.disconnect() + + def add_user_to_group(self, user_id, group_id): + """Add a user to a group""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT OR IGNORE INTO user_group_members (user_id, group_id) + VALUES (?, ?) + ''', (user_id, group_id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error adding user to group: {e}") + return False + finally: + self.db_manager.disconnect() + + def remove_user_from_group(self, user_id, group_id): + """Remove a user from a group""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + DELETE FROM user_group_members + WHERE user_id = ? AND group_id = ? + ''', (user_id, group_id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error removing user from group: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_user_groups(self, user_id): + """Get all groups for a user""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + SELECT g.id, g.name, g.permissions + FROM user_groups g + JOIN user_group_members m ON g.id = m.group_id + WHERE m.user_id = ? + ''', (user_id,)) + + groups = [] + for row in cursor.fetchall(): + permissions = row[2].split(',') if row[2] else [] + groups.append({ + 'id': row[0], + 'name': row[1], + 'permissions': permissions + }) + return groups + except Exception as e: + print(f"Error getting user groups: {e}") + return [] + finally: + self.db_manager.disconnect() + + def initialize_default_groups(self): + """Initialize default groups with permissions""" + # Check if default groups already exist + groups = self.get_all_groups() + default_group_names = ['Purchase Manager', 'Manufacturing Manager', 'Sales Manager', 'Inventory Manager', 'Reports Viewer'] + + # Check if all default groups exist + existing_group_names = [group['name'] for group in groups] + if all(name in existing_group_names for name in default_group_names): + return True + + # Create default groups + default_groups = [ + { + 'name': 'Purchase Manager', + 'permissions': ['view_dashboard', 'view_purchase_orders', 'edit_purchase_orders', 'manage_suppliers', 'receive_purchase_orders'] + }, + { + 'name': 'Manufacturing Manager', + 'permissions': ['view_dashboard', 'view_manufacturing_orders', 'edit_manufacturing_orders', 'complete_manufacturing_orders'] + }, + { + 'name': 'Sales Manager', + 'permissions': ['view_dashboard', 'view_sales_orders', 'edit_sales_orders', 'manage_customers', 'deliver_sales_orders'] + }, + { + 'name': 'Inventory Manager', + 'permissions': ['view_dashboard', 'view_inventory', 'adjust_inventory', 'view_stock_movements'] + }, + { + 'name': 'Reports Viewer', + 'permissions': ['view_dashboard', 'view_reports', 'view_inventory_report', 'view_stock_movement_report'] + } + ] + + for group in default_groups: + # Skip if group already exists + if group['name'] in existing_group_names: + continue + if not self.create_group(group['name'], group['permissions']): + return False + + return True \ No newline at end of file diff --git a/src/dao.py b/src/dao.py new file mode 100644 index 0000000..5556f40 --- /dev/null +++ b/src/dao.py @@ -0,0 +1,901 @@ +import sqlite3 +from datetime import datetime, date +from typing import List, Optional +from src.models import Product, Supplier, Customer, PurchaseOrder, PurchaseOrderItem, ManufacturingOrder, SalesOrder, SalesOrderItem, Inventory + + +class BaseDAO: + """Base DAO class with common database operations""" + + def __init__(self, db_manager): + self.db_manager = db_manager + + +class ProductDAO(BaseDAO): + """Data access object for Product model""" + + def create(self, product: Product) -> bool: + """Create a new product""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO products (name, description, unit_price, sku, min_stock) + VALUES (?, ?, ?, ?, ?) + ''', (product.name, product.description, product.unit_price, product.sku, product.min_stock)) + + product.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating product: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, product_id: int) -> Optional[Product]: + """Get product by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM products WHERE id = ?', (product_id,)) + row = cursor.fetchone() + + if row: + return Product( + id=row['id'], + name=row['name'], + description=row['description'], + unit_price=row['unit_price'], + sku=row['sku'], + min_stock=row['min_stock'] if 'min_stock' in row.keys() else 0 + ) + return None + except Exception as e: + print(f"Error getting product: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[Product]: + """Get all products""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM products ORDER BY name') + rows = cursor.fetchall() + + products = [] + for row in rows: + products.append(Product( + id=row['id'], + name=row['name'], + description=row['description'], + unit_price=row['unit_price'], + sku=row['sku'], + min_stock=row['min_stock'] if 'min_stock' in row.keys() else 0 + )) + return products + except Exception as e: + print(f"Error getting products: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, product: Product) -> bool: + """Update a product""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE products + SET name = ?, description = ?, unit_price = ?, sku = ?, min_stock = ? + WHERE id = ? + ''', (product.name, product.description, product.unit_price, product.sku, product.min_stock, product.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating product: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, product_id: int) -> bool: + """Delete a product""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('DELETE FROM products WHERE id = ?', (product_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting product: {e}") + return False + finally: + self.db_manager.disconnect() + + +class SupplierDAO(BaseDAO): + """Data access object for Supplier model""" + + def create(self, supplier: Supplier) -> bool: + """Create a new supplier""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO suppliers (name, contact_person, phone, email, address) + VALUES (?, ?, ?, ?, ?) + ''', (supplier.name, supplier.contact_person, supplier.phone, supplier.email, supplier.address)) + + supplier.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating supplier: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, supplier_id: int) -> Optional[Supplier]: + """Get supplier by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM suppliers WHERE id = ?', (supplier_id,)) + row = cursor.fetchone() + + if row: + return Supplier( + id=row['id'], + name=row['name'], + contact_person=row['contact_person'], + phone=row['phone'], + email=row['email'], + address=row['address'] + ) + return None + except Exception as e: + print(f"Error getting supplier: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[Supplier]: + """Get all suppliers""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM suppliers ORDER BY name') + rows = cursor.fetchall() + + suppliers = [] + for row in rows: + suppliers.append(Supplier( + id=row['id'], + name=row['name'], + contact_person=row['contact_person'], + phone=row['phone'], + email=row['email'], + address=row['address'] + )) + return suppliers + except Exception as e: + print(f"Error getting suppliers: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, supplier: Supplier) -> bool: + """Update a supplier""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE suppliers + SET name = ?, contact_person = ?, phone = ?, email = ?, address = ? + WHERE id = ? + ''', (supplier.name, supplier.contact_person, supplier.phone, supplier.email, supplier.address, supplier.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating supplier: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, supplier_id: int) -> bool: + """Delete a supplier""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('DELETE FROM suppliers WHERE id = ?', (supplier_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting supplier: {e}") + return False + finally: + self.db_manager.disconnect() + + +class CustomerDAO(BaseDAO): + """Data access object for Customer model""" + + def create(self, customer: Customer) -> bool: + """Create a new customer""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO customers (name, contact_person, phone, email, address) + VALUES (?, ?, ?, ?, ?) + ''', (customer.name, customer.contact_person, customer.phone, customer.email, customer.address)) + + customer.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating customer: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, customer_id: int) -> Optional[Customer]: + """Get customer by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM customers WHERE id = ?', (customer_id,)) + row = cursor.fetchone() + + if row: + return Customer( + id=row['id'], + name=row['name'], + contact_person=row['contact_person'], + phone=row['phone'], + email=row['email'], + address=row['address'] + ) + return None + except Exception as e: + print(f"Error getting customer: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[Customer]: + """Get all customers""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM customers ORDER BY name') + rows = cursor.fetchall() + + customers = [] + for row in rows: + customers.append(Customer( + id=row['id'], + name=row['name'], + contact_person=row['contact_person'], + phone=row['phone'], + email=row['email'], + address=row['address'] + )) + return customers + except Exception as e: + print(f"Error getting customers: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, customer: Customer) -> bool: + """Update a customer""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE customers + SET name = ?, contact_person = ?, phone = ?, email = ?, address = ? + WHERE id = ? + ''', (customer.name, customer.contact_person, customer.phone, customer.email, customer.address, customer.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating customer: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, customer_id: int) -> bool: + """Delete a customer""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('DELETE FROM customers WHERE id = ?', (customer_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting customer: {e}") + return False + finally: + self.db_manager.disconnect() + + +class PurchaseOrderDAO(BaseDAO): + """Data access object for PurchaseOrder model""" + + def create(self, purchase_order: PurchaseOrder) -> bool: + """Create a new purchase order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO purchase_orders (supplier_id, order_date, expected_delivery, status, total_amount) + VALUES (?, ?, ?, ?, ?) + ''', (purchase_order.supplier_id, + purchase_order.order_date.isoformat() if purchase_order.order_date else None, + purchase_order.expected_delivery.isoformat() if purchase_order.expected_delivery else None, + purchase_order.status, + purchase_order.total_amount)) + + purchase_order.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating purchase order: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, po_id: int) -> Optional[PurchaseOrder]: + """Get purchase order by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM purchase_orders WHERE id = ?', (po_id,)) + row = cursor.fetchone() + + if row: + order_date = datetime.fromisoformat(row['order_date']).date() if row['order_date'] else None + expected_delivery = datetime.fromisoformat(row['expected_delivery']).date() if row['expected_delivery'] else None + + return PurchaseOrder( + id=row['id'], + supplier_id=row['supplier_id'], + order_date=order_date, + expected_delivery=expected_delivery, + status=row['status'], + total_amount=row['total_amount'] + ) + return None + except Exception as e: + print(f"Error getting purchase order: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[PurchaseOrder]: + """Get all purchase orders""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM purchase_orders ORDER BY order_date DESC') + rows = cursor.fetchall() + + purchase_orders = [] + for row in rows: + order_date = datetime.fromisoformat(row['order_date']).date() if row['order_date'] else None + expected_delivery = datetime.fromisoformat(row['expected_delivery']).date() if row['expected_delivery'] else None + + purchase_orders.append(PurchaseOrder( + id=row['id'], + supplier_id=row['supplier_id'], + order_date=order_date, + expected_delivery=expected_delivery, + status=row['status'], + total_amount=row['total_amount'] + )) + return purchase_orders + except Exception as e: + print(f"Error getting purchase orders: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, purchase_order: PurchaseOrder) -> bool: + """Update a purchase order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE purchase_orders + SET supplier_id = ?, order_date = ?, expected_delivery = ?, status = ?, total_amount = ? + WHERE id = ? + ''', (purchase_order.supplier_id, + purchase_order.order_date.isoformat() if purchase_order.order_date else None, + purchase_order.expected_delivery.isoformat() if purchase_order.expected_delivery else None, + purchase_order.status, + purchase_order.total_amount, + purchase_order.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating purchase order: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, po_id: int) -> bool: + """Delete a purchase order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + # Delete items first + cursor.execute('DELETE FROM purchase_order_items WHERE purchase_order_id = ?', (po_id,)) + # Delete order + cursor.execute('DELETE FROM purchase_orders WHERE id = ?', (po_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting purchase order: {e}") + return False + finally: + self.db_manager.disconnect() + + +class ManufacturingOrderDAO(BaseDAO): + """Data access object for ManufacturingOrder model""" + + def create(self, manufacturing_order: ManufacturingOrder) -> bool: + """Create a new manufacturing order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO manufacturing_orders (product_id, quantity, start_date, end_date, status) + VALUES (?, ?, ?, ?, ?) + ''', (manufacturing_order.product_id, + manufacturing_order.quantity, + manufacturing_order.start_date.isoformat() if manufacturing_order.start_date else None, + manufacturing_order.end_date.isoformat() if manufacturing_order.end_date else None, + manufacturing_order.status)) + + manufacturing_order.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating manufacturing order: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, mo_id: int) -> Optional[ManufacturingOrder]: + """Get manufacturing order by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM manufacturing_orders WHERE id = ?', (mo_id,)) + row = cursor.fetchone() + + if row: + start_date = datetime.fromisoformat(row['start_date']).date() if row['start_date'] else None + end_date = datetime.fromisoformat(row['end_date']).date() if row['end_date'] else None + + return ManufacturingOrder( + id=row['id'], + product_id=row['product_id'], + quantity=row['quantity'], + start_date=start_date, + end_date=end_date, + status=row['status'] + ) + return None + except Exception as e: + print(f"Error getting manufacturing order: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[ManufacturingOrder]: + """Get all manufacturing orders""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM manufacturing_orders ORDER BY start_date DESC') + rows = cursor.fetchall() + + manufacturing_orders = [] + for row in rows: + start_date = datetime.fromisoformat(row['start_date']).date() if row['start_date'] else None + end_date = datetime.fromisoformat(row['end_date']).date() if row['end_date'] else None + + manufacturing_orders.append(ManufacturingOrder( + id=row['id'], + product_id=row['product_id'], + quantity=row['quantity'], + start_date=start_date, + end_date=end_date, + status=row['status'] + )) + return manufacturing_orders + except Exception as e: + print(f"Error getting manufacturing orders: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, manufacturing_order: ManufacturingOrder) -> bool: + """Update a manufacturing order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE manufacturing_orders + SET product_id = ?, quantity = ?, start_date = ?, end_date = ?, status = ? + WHERE id = ? + ''', (manufacturing_order.product_id, + manufacturing_order.quantity, + manufacturing_order.start_date.isoformat() if manufacturing_order.start_date else None, + manufacturing_order.end_date.isoformat() if manufacturing_order.end_date else None, + manufacturing_order.status, + manufacturing_order.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating manufacturing order: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, mo_id: int) -> bool: + """Delete a manufacturing order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('DELETE FROM manufacturing_orders WHERE id = ?', (mo_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting manufacturing order: {e}") + return False + finally: + self.db_manager.disconnect() + + +class SalesOrderDAO(BaseDAO): + """Data access object for SalesOrder model""" + + def create(self, sales_order: SalesOrder) -> bool: + """Create a new sales order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO sales_orders (customer_id, order_date, expected_delivery, status, total_amount) + VALUES (?, ?, ?, ?, ?) + ''', (sales_order.customer_id, + sales_order.order_date.isoformat() if sales_order.order_date else None, + sales_order.expected_delivery.isoformat() if sales_order.expected_delivery else None, + sales_order.status, + sales_order.total_amount)) + + sales_order.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating sales order: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, so_id: int) -> Optional[SalesOrder]: + """Get sales order by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM sales_orders WHERE id = ?', (so_id,)) + row = cursor.fetchone() + + if row: + order_date = datetime.fromisoformat(row['order_date']).date() if row['order_date'] else None + expected_delivery = datetime.fromisoformat(row['expected_delivery']).date() if row['expected_delivery'] else None + + return SalesOrder( + id=row['id'], + customer_id=row['customer_id'], + order_date=order_date, + expected_delivery=expected_delivery, + status=row['status'], + total_amount=row['total_amount'] + ) + return None + except Exception as e: + print(f"Error getting sales order: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[SalesOrder]: + """Get all sales orders""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM sales_orders ORDER BY order_date DESC') + rows = cursor.fetchall() + + sales_orders = [] + for row in rows: + order_date = datetime.fromisoformat(row['order_date']).date() if row['order_date'] else None + expected_delivery = datetime.fromisoformat(row['expected_delivery']).date() if row['expected_delivery'] else None + + sales_orders.append(SalesOrder( + id=row['id'], + customer_id=row['customer_id'], + order_date=order_date, + expected_delivery=expected_delivery, + status=row['status'], + total_amount=row['total_amount'] + )) + return sales_orders + except Exception as e: + print(f"Error getting sales orders: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, sales_order: SalesOrder) -> bool: + """Update a sales order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE sales_orders + SET customer_id = ?, order_date = ?, expected_delivery = ?, status = ?, total_amount = ? + WHERE id = ? + ''', (sales_order.customer_id, + sales_order.order_date.isoformat() if sales_order.order_date else None, + sales_order.expected_delivery.isoformat() if sales_order.expected_delivery else None, + sales_order.status, + sales_order.total_amount, + sales_order.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating sales order: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, so_id: int) -> bool: + """Delete a sales order""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + # Delete items first + cursor.execute('DELETE FROM sales_order_items WHERE sales_order_id = ?', (so_id,)) + # Delete order + cursor.execute('DELETE FROM sales_orders WHERE id = ?', (so_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting sales order: {e}") + return False + finally: + self.db_manager.disconnect() + + +class InventoryDAO(BaseDAO): + """Data access object for Inventory model""" + + def create(self, inventory: Inventory) -> bool: + """Create a new inventory record""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT OR IGNORE INTO inventory (product_id, quantity, reserved_quantity) + VALUES (?, ?, ?) + ''', (inventory.product_id, inventory.quantity, inventory.reserved_quantity)) + + # If the record already exists, update it + cursor.execute(''' + UPDATE inventory + SET quantity = ?, reserved_quantity = ?, last_updated = CURRENT_TIMESTAMP + WHERE product_id = ? + ''', (inventory.quantity, inventory.reserved_quantity, inventory.product_id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating inventory record: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_product_id(self, product_id: int) -> Optional[Inventory]: + """Get inventory by product ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM inventory WHERE product_id = ?', (product_id,)) + row = cursor.fetchone() + + if row: + last_updated = datetime.fromisoformat(row['last_updated']) if row['last_updated'] else None + + return Inventory( + id=row['id'], + product_id=row['product_id'], + quantity=row['quantity'], + reserved_quantity=row['reserved_quantity'] + ) + return None + except Exception as e: + print(f"Error getting inventory: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[Inventory]: + """Get all inventory records""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM inventory ORDER BY product_id') + rows = cursor.fetchall() + + inventory_records = [] + for row in rows: + last_updated = datetime.fromisoformat(row['last_updated']) if row['last_updated'] else None + + inventory_records.append(Inventory( + id=row['id'], + product_id=row['product_id'], + quantity=row['quantity'], + reserved_quantity=row['reserved_quantity'] + )) + return inventory_records + except Exception as e: + print(f"Error getting inventory records: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, inventory: Inventory) -> bool: + """Update an inventory record""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE inventory + SET quantity = ?, reserved_quantity = ?, last_updated = CURRENT_TIMESTAMP + WHERE product_id = ? + ''', (inventory.quantity, inventory.reserved_quantity, inventory.product_id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating inventory: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, product_id: int) -> bool: + """Delete an inventory record""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('DELETE FROM inventory WHERE product_id = ?', (product_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting inventory: {e}") + return False + finally: + self.db_manager.disconnect() + + def adjust_quantity(self, product_id: int, quantity_change: int) -> bool: + """Adjust inventory quantity""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE inventory + SET quantity = quantity + ?, last_updated = CURRENT_TIMESTAMP + WHERE product_id = ? + ''', (quantity_change, product_id)) + + # If no rows were updated, insert a new record + if cursor.rowcount == 0: + cursor.execute(''' + INSERT INTO inventory (product_id, quantity, reserved_quantity) + VALUES (?, ?, 0) + ''', (product_id, quantity_change)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error adjusting inventory: {e}") + return False + finally: + self.db_manager.disconnect() \ No newline at end of file diff --git a/src/dao_items.py b/src/dao_items.py new file mode 100644 index 0000000..a9e9c72 --- /dev/null +++ b/src/dao_items.py @@ -0,0 +1,295 @@ +import sqlite3 +from datetime import datetime, date +from typing import List, Optional +from src.models import PurchaseOrderItem, SalesOrderItem + + +class BaseDAO: + """Base DAO class with common database operations""" + + def __init__(self, db_manager): + self.db_manager = db_manager + + +class PurchaseOrderItemDAO(BaseDAO): + """Data access object for PurchaseOrderItem model""" + + def create(self, item: PurchaseOrderItem) -> bool: + """Create a new purchase order item""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO purchase_order_items (purchase_order_id, product_id, quantity, unit_price, total_price) + VALUES (?, ?, ?, ?, ?) + ''', (item.purchase_order_id, item.product_id, item.quantity, item.unit_price, item.total_price)) + + item.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating purchase order item: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, item_id: int) -> Optional[PurchaseOrderItem]: + """Get purchase order item by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM purchase_order_items WHERE id = ?', (item_id,)) + row = cursor.fetchone() + + if row: + return PurchaseOrderItem( + id=row['id'], + purchase_order_id=row['purchase_order_id'], + product_id=row['product_id'], + quantity=row['quantity'], + unit_price=row['unit_price'], + total_price=row['total_price'] + ) + return None + except Exception as e: + print(f"Error getting purchase order item: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_by_purchase_order_id(self, po_id: int) -> List[PurchaseOrderItem]: + """Get all items for a purchase order""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM purchase_order_items WHERE purchase_order_id = ?', (po_id,)) + rows = cursor.fetchall() + + items = [] + for row in rows: + items.append(PurchaseOrderItem( + id=row['id'], + purchase_order_id=row['purchase_order_id'], + product_id=row['product_id'], + quantity=row['quantity'], + unit_price=row['unit_price'], + total_price=row['total_price'] + )) + return items + except Exception as e: + print(f"Error getting purchase order items: {e}") + return [] + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[PurchaseOrderItem]: + """Get all purchase order items""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM purchase_order_items') + rows = cursor.fetchall() + + items = [] + for row in rows: + items.append(PurchaseOrderItem( + id=row['id'], + purchase_order_id=row['purchase_order_id'], + product_id=row['product_id'], + quantity=row['quantity'], + unit_price=row['unit_price'], + total_price=row['total_price'] + )) + return items + except Exception as e: + print(f"Error getting purchase order items: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, item: PurchaseOrderItem) -> bool: + """Update a purchase order item""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE purchase_order_items + SET purchase_order_id = ?, product_id = ?, quantity = ?, unit_price = ?, total_price = ? + WHERE id = ? + ''', (item.purchase_order_id, item.product_id, item.quantity, item.unit_price, item.total_price, item.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating purchase order item: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, item_id: int) -> bool: + """Delete a purchase order item""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('DELETE FROM purchase_order_items WHERE id = ?', (item_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting purchase order item: {e}") + return False + finally: + self.db_manager.disconnect() + + +class SalesOrderItemDAO(BaseDAO): + """Data access object for SalesOrderItem model""" + + def create(self, item: SalesOrderItem) -> bool: + """Create a new sales order item""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO sales_order_items (sales_order_id, product_id, quantity, unit_price, total_price) + VALUES (?, ?, ?, ?, ?) + ''', (item.sales_order_id, item.product_id, item.quantity, item.unit_price, item.total_price)) + + item.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating sales order item: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, item_id: int) -> Optional[SalesOrderItem]: + """Get sales order item by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM sales_order_items WHERE id = ?', (item_id,)) + row = cursor.fetchone() + + if row: + return SalesOrderItem( + id=row['id'], + sales_order_id=row['sales_order_id'], + product_id=row['product_id'], + quantity=row['quantity'], + unit_price=row['unit_price'], + total_price=row['total_price'] + ) + return None + except Exception as e: + print(f"Error getting sales order item: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_by_sales_order_id(self, so_id: int) -> List[SalesOrderItem]: + """Get all items for a sales order""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM sales_order_items WHERE sales_order_id = ?', (so_id,)) + rows = cursor.fetchall() + + items = [] + for row in rows: + items.append(SalesOrderItem( + id=row['id'], + sales_order_id=row['sales_order_id'], + product_id=row['product_id'], + quantity=row['quantity'], + unit_price=row['unit_price'], + total_price=row['total_price'] + )) + return items + except Exception as e: + print(f"Error getting sales order items: {e}") + return [] + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[SalesOrderItem]: + """Get all sales order items""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM sales_order_items') + rows = cursor.fetchall() + + items = [] + for row in rows: + items.append(SalesOrderItem( + id=row['id'], + sales_order_id=row['sales_order_id'], + product_id=row['product_id'], + quantity=row['quantity'], + unit_price=row['unit_price'], + total_price=row['total_price'] + )) + return items + except Exception as e: + print(f"Error getting sales order items: {e}") + return [] + finally: + self.db_manager.disconnect() + + def update(self, item: SalesOrderItem) -> bool: + """Update a sales order item""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + UPDATE sales_order_items + SET sales_order_id = ?, product_id = ?, quantity = ?, unit_price = ?, total_price = ? + WHERE id = ? + ''', (item.sales_order_id, item.product_id, item.quantity, item.unit_price, item.total_price, item.id)) + + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error updating sales order item: {e}") + return False + finally: + self.db_manager.disconnect() + + def delete(self, item_id: int) -> bool: + """Delete a sales order item""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('DELETE FROM sales_order_items WHERE id = ?', (item_id,)) + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error deleting sales order item: {e}") + return False + finally: + self.db_manager.disconnect() \ No newline at end of file diff --git a/src/dao_stock.py b/src/dao_stock.py new file mode 100644 index 0000000..41c2a88 --- /dev/null +++ b/src/dao_stock.py @@ -0,0 +1,156 @@ +import sqlite3 +from datetime import datetime, date +from typing import List, Optional +from src.models import StockMovement + + +class BaseDAO: + """Base DAO class with common database operations""" + + def __init__(self, db_manager): + self.db_manager = db_manager + + +class StockMovementDAO(BaseDAO): + """Data access object for StockMovement model""" + + def create(self, movement: StockMovement) -> bool: + """Create a new stock movement record""" + if not self.db_manager.connect(): + return False + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + INSERT INTO stock_movements (product_id, movement_type, quantity, reference_type, reference_id) + VALUES (?, ?, ?, ?, ?) + ''', (movement.product_id, movement.movement_type, movement.quantity, + movement.reference_type, movement.reference_id)) + + movement.id = cursor.lastrowid + self.db_manager.connection.commit() + return True + except Exception as e: + print(f"Error creating stock movement: {e}") + return False + finally: + self.db_manager.disconnect() + + def get_by_id(self, movement_id: int) -> Optional[StockMovement]: + """Get stock movement by ID""" + if not self.db_manager.connect(): + return None + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM stock_movements WHERE id = ?', (movement_id,)) + row = cursor.fetchone() + + if row: + created_at = datetime.fromisoformat(row['created_at']) if row['created_at'] else None + + return StockMovement( + id=row['id'], + product_id=row['product_id'], + movement_type=row['movement_type'], + quantity=row['quantity'], + reference_type=row['reference_type'], + reference_id=row['reference_id'] + ) + return None + except Exception as e: + print(f"Error getting stock movement: {e}") + return None + finally: + self.db_manager.disconnect() + + def get_by_product_id(self, product_id: int) -> List[StockMovement]: + """Get all stock movements for a product""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM stock_movements WHERE product_id = ? ORDER BY created_at DESC', (product_id,)) + rows = cursor.fetchall() + + movements = [] + for row in rows: + created_at = datetime.fromisoformat(row['created_at']) if row['created_at'] else None + + movements.append(StockMovement( + id=row['id'], + product_id=row['product_id'], + movement_type=row['movement_type'], + quantity=row['quantity'], + reference_type=row['reference_type'], + reference_id=row['reference_id'] + )) + return movements + except Exception as e: + print(f"Error getting stock movements: {e}") + return [] + finally: + self.db_manager.disconnect() + + def get_all(self) -> List[StockMovement]: + """Get all stock movements""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute('SELECT * FROM stock_movements ORDER BY created_at DESC') + rows = cursor.fetchall() + + movements = [] + for row in rows: + created_at = datetime.fromisoformat(row['created_at']) if row['created_at'] else None + + movements.append(StockMovement( + id=row['id'], + product_id=row['product_id'], + movement_type=row['movement_type'], + quantity=row['quantity'], + reference_type=row['reference_type'], + reference_id=row['reference_id'] + )) + return movements + except Exception as e: + print(f"Error getting stock movements: {e}") + return [] + finally: + self.db_manager.disconnect() + + def get_by_date_range(self, start_date: date, end_date: date) -> List[StockMovement]: + """Get stock movements within a date range""" + if not self.db_manager.connect(): + return [] + + try: + cursor = self.db_manager.connection.cursor() + cursor.execute(''' + SELECT * FROM stock_movements + WHERE DATE(created_at) BETWEEN ? AND ? + ORDER BY created_at DESC + ''', (start_date.isoformat(), end_date.isoformat())) + rows = cursor.fetchall() + + movements = [] + for row in rows: + created_at = datetime.fromisoformat(row['created_at']) if row['created_at'] else None + + movements.append(StockMovement( + id=row['id'], + product_id=row['product_id'], + movement_type=row['movement_type'], + quantity=row['quantity'], + reference_type=row['reference_type'], + reference_id=row['reference_id'] + )) + return movements + except Exception as e: + print(f"Error getting stock movements by date range: {e}") + return [] + finally: + self.db_manager.disconnect() \ No newline at end of file diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..1345630 --- /dev/null +++ b/src/database.py @@ -0,0 +1,325 @@ +import sqlite3 +import os +import json +from datetime import datetime +import shutil + + +class DatabaseManager: + """Database manager for the manufacturing app""" + + def __init__(self, db_path=None): + """Initialize the database manager""" + self.db_path = db_path or "manufacturing.db" + self.config_path = "db_config.json" + self.connection = None + self.load_config() + + def load_config(self): + """Load database configuration""" + if os.path.exists(self.config_path): + with open(self.config_path, 'r') as f: + config = json.load(f) + self.db_type = config.get('db_type', 'sqlite') + self.db_host = config.get('db_host', '') + self.db_port = config.get('db_port', '') + self.db_name = config.get('db_name', '') + self.db_user = config.get('db_user', '') + self.db_password = config.get('db_password', '') + else: + # Default to SQLite + self.db_type = 'sqlite' + self.db_host = '' + self.db_port = '' + self.db_name = self.db_path + self.db_user = '' + self.db_password = '' + + def save_config(self): + """Save database configuration""" + config = { + 'db_type': self.db_type, + 'db_host': self.db_host, + 'db_port': self.db_port, + 'db_name': self.db_name, + 'db_user': self.db_user, + 'db_password': self.db_password + } + + with open(self.config_path, 'w') as f: + json.dump(config, f, indent=2) + + def connect(self): + """Connect to the database""" + try: + if self.db_type == 'sqlite': + self.connection = sqlite3.connect(self.db_name) + self.connection.row_factory = sqlite3.Row + elif self.db_type == 'postgresql': + # For PostgreSQL, we would use psycopg2 + # This is a placeholder implementation + import psycopg2 + self.connection = psycopg2.connect( + host=self.db_host, + port=self.db_port, + database=self.db_name, + user=self.db_user, + password=self.db_password + ) + return True + except Exception as e: + print(f"Database connection error: {e}") + return False + + def disconnect(self): + """Disconnect from the database""" + if self.connection: + self.connection.close() + self.connection = None + + def initialize_database(self): + """Initialize the database with required tables""" + if not self.connect(): + return False + + try: + cursor = self.connection.cursor() + + # Create users table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create user_groups table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + permissions TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create user_group_members table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_group_members ( + user_id INTEGER, + group_id INTEGER, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (group_id) REFERENCES user_groups (id), + PRIMARY KEY (user_id, group_id) + ) + ''') + + # Create products table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + unit_price REAL DEFAULT 0, + sku TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create suppliers table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + contact_person TEXT, + phone TEXT, + email TEXT, + address TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create customers table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + contact_person TEXT, + phone TEXT, + email TEXT, + address TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Create purchase_orders table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS purchase_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + supplier_id INTEGER, + order_date DATE, + expected_delivery DATE, + status TEXT DEFAULT 'pending', + total_amount REAL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (supplier_id) REFERENCES suppliers (id) + ) + ''') + + # Create purchase_order_items table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS purchase_order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_order_id INTEGER, + product_id INTEGER, + quantity INTEGER, + unit_price REAL, + total_price REAL, + FOREIGN KEY (purchase_order_id) REFERENCES purchase_orders (id), + FOREIGN KEY (product_id) REFERENCES products (id) + ) + ''') + + # Create manufacturing_orders table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS manufacturing_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER, + quantity INTEGER, + start_date DATE, + end_date DATE, + status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products (id) + ) + ''') + + # Create sales_orders table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sales_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER, + order_date DATE, + expected_delivery DATE, + status TEXT DEFAULT 'pending', + total_amount REAL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers (id) + ) + ''') + + # Create sales_order_items table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sales_order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sales_order_id INTEGER, + product_id INTEGER, + quantity INTEGER, + unit_price REAL, + total_price REAL, + FOREIGN KEY (sales_order_id) REFERENCES sales_orders (id), + FOREIGN KEY (product_id) REFERENCES products (id) + ) + ''') + + # Create inventory table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER UNIQUE, + quantity INTEGER DEFAULT 0, + reserved_quantity INTEGER DEFAULT 0, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products (id) + ) + ''') + + # Create stock_movements table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS stock_movements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER, + movement_type TEXT, -- 'IN' or 'OUT' + quantity INTEGER, + reference_type TEXT, -- 'PO', 'SO', 'MO', 'ADJUSTMENT' + reference_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products (id) + ) + ''') + + # Create stock movements table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS stock_movements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER, + movement_type TEXT, -- 'IN' for receipts, 'OUT' for issues + quantity INTEGER, + reference_type TEXT, -- 'PO', 'SO', 'MO', 'ADJUSTMENT' + reference_id INTEGER, -- ID of the related document + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products (id) + ) + ''') + + self.connection.commit() + return True + except Exception as e: + print(f"Database initialization error: {e}") + return False + finally: + self.disconnect() + + def backup_database(self, backup_path=None): + """Backup the database""" + try: + if not backup_path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = f"backup_{timestamp}.db" + + if self.db_type == 'sqlite': + shutil.copy2(self.db_name, backup_path) + return backup_path + else: + # For other databases, implement appropriate backup logic + return None + except Exception as e: + print(f"Database backup error: {e}") + return None + + def restore_database(self, backup_path): + """Restore the database from backup""" + try: + if self.db_type == 'sqlite': + shutil.copy2(backup_path, self.db_name) + return True + else: + # For other databases, implement appropriate restore logic + return False + except Exception as e: + print(f"Database restore error: {e}") + return False + + def duplicate_database(self, new_name): + """Duplicate the database""" + try: + if self.db_type == 'sqlite': + shutil.copy2(self.db_name, new_name) + return new_name + else: + # For other databases, implement appropriate duplication logic + return None + except Exception as e: + print(f"Database duplication error: {e}") + return None + + def get_connection_string(self): + """Get database connection string""" + if self.db_type == 'sqlite': + return f"sqlite:///{self.db_name}" + elif self.db_type == 'postgresql': + return f"postgresql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" + return "" \ No newline at end of file diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..9d3ee81 --- /dev/null +++ b/src/models.py @@ -0,0 +1,240 @@ +import sqlite3 +from datetime import datetime, date +from typing import List, Optional + + +class Product: + """Product model""" + + def __init__(self, id=None, name="", description="", unit_price=0.0, sku=None, min_stock=0): + self.id = id + self.name = name + self.description = description + self.unit_price = unit_price + self.sku = sku + self.min_stock = min_stock + self.created_at = datetime.now() + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'unit_price': self.unit_price, + 'sku': self.sku, + 'min_stock': self.min_stock, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class Supplier: + """Supplier model""" + + def __init__(self, id=None, name="", contact_person="", phone="", email="", address=""): + self.id = id + self.name = name + self.contact_person = contact_person + self.phone = phone + self.email = email + self.address = address + self.created_at = datetime.now() + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'contact_person': self.contact_person, + 'phone': self.phone, + 'email': self.email, + 'address': self.address, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class Customer: + """Customer model""" + + def __init__(self, id=None, name="", contact_person="", phone="", email="", address=""): + self.id = id + self.name = name + self.contact_person = contact_person + self.phone = phone + self.email = email + self.address = address + self.created_at = datetime.now() + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'contact_person': self.contact_person, + 'phone': self.phone, + 'email': self.email, + 'address': self.address, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class PurchaseOrder: + """Purchase order model""" + + def __init__(self, id=None, supplier_id=None, order_date=None, expected_delivery=None, + status="pending", total_amount=0.0): + self.id = id + self.supplier_id = supplier_id + self.order_date = order_date or date.today() + self.expected_delivery = expected_delivery + self.status = status + self.total_amount = total_amount + self.created_at = datetime.now() + self.items: List[PurchaseOrderItem] = [] + + def to_dict(self): + return { + 'id': self.id, + 'supplier_id': self.supplier_id, + 'order_date': self.order_date.isoformat() if self.order_date else None, + 'expected_delivery': self.expected_delivery.isoformat() if self.expected_delivery else None, + 'status': self.status, + 'total_amount': self.total_amount, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class PurchaseOrderItem: + """Purchase order item model""" + + def __init__(self, id=None, purchase_order_id=None, product_id=None, quantity=0, unit_price=0.0, total_price=0.0): + self.id = id + self.purchase_order_id = purchase_order_id + self.product_id = product_id + self.quantity = quantity + self.unit_price = unit_price + self.total_price = total_price + + def to_dict(self): + return { + 'id': self.id, + 'purchase_order_id': self.purchase_order_id, + 'product_id': self.product_id, + 'quantity': self.quantity, + 'unit_price': self.unit_price, + 'total_price': self.total_price + } + + +class ManufacturingOrder: + """Manufacturing order model""" + + def __init__(self, id=None, product_id=None, quantity=0, start_date=None, end_date=None, status="pending"): + self.id = id + self.product_id = product_id + self.quantity = quantity + self.start_date = start_date or date.today() + self.end_date = end_date + self.status = status + self.created_at = datetime.now() + + def to_dict(self): + return { + 'id': self.id, + 'product_id': self.product_id, + 'quantity': self.quantity, + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class SalesOrder: + """Sales order model""" + + def __init__(self, id=None, customer_id=None, order_date=None, expected_delivery=None, + status="pending", total_amount=0.0): + self.id = id + self.customer_id = customer_id + self.order_date = order_date or date.today() + self.expected_delivery = expected_delivery + self.status = status + self.total_amount = total_amount + self.created_at = datetime.now() + self.items: List[SalesOrderItem] = [] + + def to_dict(self): + return { + 'id': self.id, + 'customer_id': self.customer_id, + 'order_date': self.order_date.isoformat() if self.order_date else None, + 'expected_delivery': self.expected_delivery.isoformat() if self.expected_delivery else None, + 'status': self.status, + 'total_amount': self.total_amount, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class SalesOrderItem: + """Sales order item model""" + + def __init__(self, id=None, sales_order_id=None, product_id=None, quantity=0, unit_price=0.0, total_price=0.0): + self.id = id + self.sales_order_id = sales_order_id + self.product_id = product_id + self.quantity = quantity + self.unit_price = unit_price + self.total_price = total_price + + def to_dict(self): + return { + 'id': self.id, + 'sales_order_id': self.sales_order_id, + 'product_id': self.product_id, + 'quantity': self.quantity, + 'unit_price': self.unit_price, + 'total_price': self.total_price + } + + +class Inventory: + """Inventory model""" + + def __init__(self, id=None, product_id=None, quantity=0, reserved_quantity=0): + self.id = id + self.product_id = product_id + self.quantity = quantity + self.reserved_quantity = reserved_quantity + self.last_updated = datetime.now() + + def to_dict(self): + return { + 'id': self.id, + 'product_id': self.product_id, + 'quantity': self.quantity, + 'reserved_quantity': self.reserved_quantity, + 'last_updated': self.last_updated.isoformat() if self.last_updated else None + } + + +class StockMovement: + """Stock movement model""" + + def __init__(self, id=None, product_id=None, movement_type="", quantity=0, + reference_type="", reference_id=None): + self.id = id + self.product_id = product_id + self.movement_type = movement_type # 'IN' or 'OUT' + self.quantity = quantity + self.reference_type = reference_type # 'PO', 'SO', 'MO', 'ADJUSTMENT' + self.reference_id = reference_id + self.created_at = datetime.now() + + def to_dict(self): + return { + 'id': self.id, + 'product_id': self.product_id, + 'movement_type': self.movement_type, + 'quantity': self.quantity, + 'reference_type': self.reference_type, + 'reference_id': self.reference_id, + 'created_at': self.created_at.isoformat() if self.created_at else None + } \ No newline at end of file diff --git a/src/services.py b/src/services.py new file mode 100644 index 0000000..ad01e76 --- /dev/null +++ b/src/services.py @@ -0,0 +1,699 @@ +from typing import List, Optional +import pandas as pd +from datetime import datetime, date +import os + +from src.models import Product, Supplier, Customer, PurchaseOrder, PurchaseOrderItem, ManufacturingOrder, SalesOrder, SalesOrderItem, Inventory, StockMovement +from src.dao import ProductDAO, SupplierDAO, CustomerDAO, PurchaseOrderDAO, ManufacturingOrderDAO, SalesOrderDAO, InventoryDAO +from src.dao_items import PurchaseOrderItemDAO, SalesOrderItemDAO +from src.dao_stock import StockMovementDAO + + +class ProductService: + """Service for managing products""" + + def __init__(self, db_manager): + self.dao = ProductDAO(db_manager) + + def create_product(self, name: str, description: str = "", unit_price: float = 0.0) -> Optional[Product]: + """Create a new product""" + product = Product(name=name, description=description, unit_price=unit_price) + if self.dao.create(product): + return product + return None + + def get_product(self, product_id: int) -> Optional[Product]: + """Get product by ID""" + return self.dao.get_by_id(product_id) + + def get_all_products(self) -> List[Product]: + """Get all products""" + return self.dao.get_all() + + def update_product(self, product: Product) -> bool: + """Update a product""" + return self.dao.update(product) + + def delete_product(self, product_id: int) -> bool: + """Delete a product""" + return self.dao.delete(product_id) + + +class SupplierService: + """Service for managing suppliers""" + + def __init__(self, db_manager): + self.dao = SupplierDAO(db_manager) + + def create_supplier(self, name: str, contact_person: str = "", phone: str = "", + email: str = "", address: str = "") -> Optional[Supplier]: + """Create a new supplier""" + supplier = Supplier(name=name, contact_person=contact_person, phone=phone, + email=email, address=address) + if self.dao.create(supplier): + return supplier + return None + + def get_supplier(self, supplier_id: int) -> Optional[Supplier]: + """Get supplier by ID""" + return self.dao.get_by_id(supplier_id) + + def get_all_suppliers(self) -> List[Supplier]: + """Get all suppliers""" + return self.dao.get_all() + + def update_supplier(self, supplier: Supplier) -> bool: + """Update a supplier""" + return self.dao.update(supplier) + + def delete_supplier(self, supplier_id: int) -> bool: + """Delete a supplier""" + return self.dao.delete(supplier_id) + + +class CustomerService: + """Service for managing customers""" + + def __init__(self, db_manager): + self.dao = CustomerDAO(db_manager) + + def create_customer(self, name: str, contact_person: str = "", phone: str = "", + email: str = "", address: str = "") -> Optional[Customer]: + """Create a new customer""" + customer = Customer(name=name, contact_person=contact_person, phone=phone, + email=email, address=address) + if self.dao.create(customer): + return customer + return None + + def get_customer(self, customer_id: int) -> Optional[Customer]: + """Get customer by ID""" + return self.dao.get_by_id(customer_id) + + def get_all_customers(self) -> List[Customer]: + """Get all customers""" + return self.dao.get_all() + + def update_customer(self, customer: Customer) -> bool: + """Update a customer""" + return self.dao.update(customer) + + def delete_customer(self, customer_id: int) -> bool: + """Delete a customer""" + return self.dao.delete(customer_id) + + +class PurchaseOrderService: + """Service for managing purchase orders""" + + def __init__(self, db_manager): + self.dao = PurchaseOrderDAO(db_manager) + self.item_dao = PurchaseOrderItemDAO(db_manager) + self.product_service = ProductService(db_manager) + self.supplier_service = SupplierService(db_manager) + self.inventory_service = InventoryService(db_manager) + + def create_purchase_order(self, supplier_id: int, order_date=None, expected_delivery=None, + status: str = "pending", total_amount: float = 0.0) -> Optional[PurchaseOrder]: + """Create a new purchase order""" + # Verify supplier exists + if not self.supplier_service.get_supplier(supplier_id): + return None + + po = PurchaseOrder(supplier_id=supplier_id, order_date=order_date, + expected_delivery=expected_delivery, status=status, + total_amount=total_amount) + if self.dao.create(po): + return po + return None + + def add_purchase_order_item(self, po_id: int, product_id: int, quantity: int, + unit_price: float, total_price: float) -> bool: + """Add an item to a purchase order""" + # Verify purchase order exists + po = self.get_purchase_order(po_id) + if not po: + return False + + # Verify product exists + if not self.product_service.get_product(product_id): + return False + + # Create item + item = PurchaseOrderItem( + purchase_order_id=po_id, + product_id=product_id, + quantity=quantity, + unit_price=unit_price, + total_price=total_price + ) + + return self.item_dao.create(item) + + def get_purchase_order(self, po_id: int) -> Optional[PurchaseOrder]: + """Get purchase order by ID""" + return self.dao.get_by_id(po_id) + + def get_all_purchase_orders(self) -> List[PurchaseOrder]: + """Get all purchase orders""" + return self.dao.get_all() + + def get_pending_purchase_orders(self) -> List[PurchaseOrder]: + """Get all pending purchase orders""" + all_orders = self.get_all_purchase_orders() + return [order for order in all_orders if order.status == "pending"] + + def get_recent_purchase_orders(self, limit: int = 5) -> List[PurchaseOrder]: + """Get recent purchase orders""" + all_orders = self.get_all_purchase_orders() + # Sort by created_at in descending order and return the limit + sorted_orders = sorted(all_orders, key=lambda x: x.created_at, reverse=True) + return sorted_orders[:limit] + + def update_purchase_order(self, po: PurchaseOrder) -> bool: + """Update a purchase order""" + return self.dao.update(po) + + def delete_purchase_order(self, po_id: int) -> bool: + """Delete a purchase order""" + return self.dao.delete(po_id) + + def receive_purchase_order(self, po_id: int) -> bool: + """Receive products from a purchase order and update inventory""" + po = self.get_purchase_order(po_id) + if not po or po.status != "pending": + return False + + # Get all items for this purchase order + items = self.item_dao.get_by_purchase_order_id(po_id) + + # Update inventory for each item + for item in items: + if not self.inventory_service.adjust_inventory(item.product_id, item.quantity, "PO", po_id): + return False + + # Update order status + po.status = "completed" + if self.update_purchase_order(po): + return True + return False + + def get_purchase_order_cost(self, po_id: int) -> float: + """Get total cost for a purchase order""" + po = self.get_purchase_order(po_id) + if not po: + return 0.0 + return po.total_amount + + def get_all_costs(self) -> float: + """Get total costs from all completed purchase orders""" + total_cost = 0.0 + purchase_orders = self.get_all_purchase_orders() + for po in purchase_orders: + if po.status == "completed": + total_cost += po.total_amount + return total_cost + + def get_total_costs(self) -> float: + """Get total costs from all completed purchase orders""" + return self.get_all_costs() + + def export_to_excel(self, filename: str = None, start_date: date = None, end_date: date = None) -> str: + """Export purchase orders to Excel with optional date range filtering""" + if filename is None: + filename = f"purchase_orders_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + purchase_orders = self.get_all_purchase_orders() + + # Filter by date range if provided + if start_date and end_date: + purchase_orders = [po for po in purchase_orders if start_date <= po.order_date <= end_date] + elif start_date: + purchase_orders = [po for po in purchase_orders if po.order_date >= start_date] + elif end_date: + purchase_orders = [po for po in purchase_orders if po.order_date <= end_date] + + # Prepare data for DataFrame + data = [] + for po in purchase_orders: + supplier = self.supplier_service.get_supplier(po.supplier_id) + supplier_name = supplier.name if supplier else "Unknown" + + data.append({ + 'Order ID': po.id, + 'Supplier': supplier_name, + 'Order Date': po.order_date, + 'Expected Delivery': po.expected_delivery, + 'Status': po.status, + 'Total Amount': po.total_amount + }) + + # Create DataFrame and export + df = pd.DataFrame(data) + + # Ensure directory exists + os.makedirs(os.path.dirname(filename) if os.path.dirname(filename) else '.', exist_ok=True) + + df.to_excel(filename, index=False, engine='openpyxl') + return filename + + def export_purchase_report(self, start_date: date = None, end_date: date = None) -> str: + """Export purchase report to Excel with optional date range""" + return self.export_to_excel(start_date=start_date, end_date=end_date) + + +class ManufacturingOrderService: + """Service for managing manufacturing orders""" + + def __init__(self, db_manager): + self.dao = ManufacturingOrderDAO(db_manager) + self.product_service = ProductService(db_manager) + self.inventory_service = InventoryService(db_manager) + + def create_manufacturing_order(self, product_id: int, quantity: int, start_date=None, + end_date=None, status: str = "pending") -> Optional[ManufacturingOrder]: + """Create a new manufacturing order""" + # Verify product exists + if not self.product_service.get_product(product_id): + return None + + mo = ManufacturingOrder(product_id=product_id, quantity=quantity, + start_date=start_date, end_date=end_date, status=status) + if self.dao.create(mo): + return mo + return None + + def get_manufacturing_order(self, mo_id: int) -> Optional[ManufacturingOrder]: + """Get manufacturing order by ID""" + return self.dao.get_by_id(mo_id) + + def get_all_manufacturing_orders(self) -> List[ManufacturingOrder]: + """Get all manufacturing orders""" + return self.dao.get_all() + + def get_active_manufacturing_orders(self) -> List[ManufacturingOrder]: + """Get all active manufacturing orders""" + all_orders = self.get_all_manufacturing_orders() + return [order for order in all_orders if order.status == "pending"] + + def get_recent_manufacturing_orders(self, limit: int = 5) -> List[ManufacturingOrder]: + """Get recent manufacturing orders""" + all_orders = self.get_all_manufacturing_orders() + # Sort by created_at in descending order and return the limit + sorted_orders = sorted(all_orders, key=lambda x: x.created_at, reverse=True) + return sorted_orders[:limit] + + def update_manufacturing_order(self, mo: ManufacturingOrder) -> bool: + """Update a manufacturing order""" + return self.dao.update(mo) + + def delete_manufacturing_order(self, mo_id: int) -> bool: + """Delete a manufacturing order""" + return self.dao.delete(mo_id) + + def complete_manufacturing_order(self, mo_id: int) -> bool: + """Complete a manufacturing order and update inventory""" + mo = self.get_manufacturing_order(mo_id) + if not mo or mo.status != "pending": + return False + + # Update inventory + if self.inventory_service.adjust_inventory(mo.product_id, mo.quantity, "MO", mo_id): + # Update order status + mo.status = "completed" + return self.update_manufacturing_order(mo) + return False + + def export_to_excel(self, filename: str = None, start_date: date = None, end_date: date = None) -> str: + """Export manufacturing orders to Excel with optional date range filtering""" + if filename is None: + filename = f"manufacturing_orders_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + manufacturing_orders = self.get_all_manufacturing_orders() + + # Filter by date range if provided + if start_date and end_date: + manufacturing_orders = [mo for mo in manufacturing_orders if start_date <= mo.start_date <= end_date] + elif start_date: + manufacturing_orders = [mo for mo in manufacturing_orders if mo.start_date >= start_date] + elif end_date: + manufacturing_orders = [mo for mo in manufacturing_orders if mo.start_date <= end_date] + + # Prepare data for DataFrame + data = [] + for mo in manufacturing_orders: + product = self.product_service.get_product(mo.product_id) + product_name = product.name if product else "Unknown" + + data.append({ + 'Order ID': mo.id, + 'Product': product_name, + 'Quantity': mo.quantity, + 'Start Date': mo.start_date, + 'End Date': mo.end_date, + 'Status': mo.status + }) + + # Create DataFrame and export + df = pd.DataFrame(data) + + # Ensure directory exists + os.makedirs(os.path.dirname(filename) if os.path.dirname(filename) else '.', exist_ok=True) + + df.to_excel(filename, index=False, engine='openpyxl') + return filename + + def export_manufacturing_report(self, start_date: date = None, end_date: date = None) -> str: + """Export manufacturing report to Excel with optional date range""" + return self.export_to_excel(start_date=start_date, end_date=end_date) + + def get_total_costs(self) -> float: + """Get total costs from all completed manufacturing orders""" + total_cost = 0.0 + manufacturing_orders = self.get_all_manufacturing_orders() + for mo in manufacturing_orders: + if mo.status == "completed": + # Get the product to calculate cost + product = self.product_service.get_product(mo.product_id) + if product: + total_cost += product.unit_price * mo.quantity + return total_cost + + +class SalesOrderService: + """Service for managing sales orders""" + + def __init__(self, db_manager): + self.dao = SalesOrderDAO(db_manager) + self.item_dao = SalesOrderItemDAO(db_manager) + self.product_service = ProductService(db_manager) + self.customer_service = CustomerService(db_manager) + self.inventory_service = InventoryService(db_manager) + + def create_sales_order(self, customer_id: int, order_date=None, expected_delivery=None, + status: str = "pending", total_amount: float = 0.0) -> Optional[SalesOrder]: + """Create a new sales order""" + # Verify customer exists + if not self.customer_service.get_customer(customer_id): + return None + + so = SalesOrder(customer_id=customer_id, order_date=order_date, + expected_delivery=expected_delivery, status=status, + total_amount=total_amount) + if self.dao.create(so): + return so + return None + + def add_sales_order_item(self, so_id: int, product_id: int, quantity: int, + unit_price: float, total_price: float) -> bool: + """Add an item to a sales order""" + # Verify sales order exists + so = self.get_sales_order(so_id) + if not so: + return False + + # Verify product exists + if not self.product_service.get_product(product_id): + return False + + # Create item + item = SalesOrderItem( + sales_order_id=so_id, + product_id=product_id, + quantity=quantity, + unit_price=unit_price, + total_price=total_price + ) + + return self.item_dao.create(item) + + def get_sales_order(self, so_id: int) -> Optional[SalesOrder]: + """Get sales order by ID""" + return self.dao.get_by_id(so_id) + + def get_all_sales_orders(self) -> List[SalesOrder]: + """Get all sales orders""" + return self.dao.get_all() + + def get_pending_sales_orders(self) -> List[SalesOrder]: + """Get all pending sales orders""" + all_orders = self.get_all_sales_orders() + return [order for order in all_orders if order.status == "pending"] + + def get_recent_sales_orders(self, limit: int = 5) -> List[SalesOrder]: + """Get recent sales orders""" + all_orders = self.get_all_sales_orders() + # Sort by created_at in descending order and return the limit + sorted_orders = sorted(all_orders, key=lambda x: x.created_at, reverse=True) + return sorted_orders[:limit] + + def update_sales_order(self, so: SalesOrder) -> bool: + """Update a sales order""" + return self.dao.update(so) + + def delete_sales_order(self, so_id: int) -> bool: + """Delete a sales order""" + return self.dao.delete(so_id) + + def deliver_sales_order(self, so_id: int) -> bool: + """Deliver products from a sales order and update inventory""" + so = self.get_sales_order(so_id) + if not so or so.status != "pending": + return False + + # Get all items for this sales order + items = self.item_dao.get_by_sales_order_id(so_id) + + # Update inventory for each item (decrease quantity) + for item in items: + if not self.inventory_service.adjust_inventory(item.product_id, -item.quantity, "SO", so_id): + return False + + # Update order status + so.status = "completed" + if self.update_sales_order(so): + return True + return False + + def get_sales_order_revenue(self, so_id: int) -> float: + """Get total revenue for a sales order""" + so = self.get_sales_order(so_id) + if not so: + return 0.0 + return so.total_amount + + def get_all_revenue(self) -> float: + """Get total revenue from all completed sales orders""" + total_revenue = 0.0 + sales_orders = self.get_all_sales_orders() + for so in sales_orders: + if so.status == "completed": + total_revenue += so.total_amount + return total_revenue + + def get_total_revenue(self) -> float: + """Get total revenue from all completed sales orders""" + return self.get_all_revenue() + + def export_to_excel(self, filename: str = None, start_date: date = None, end_date: date = None) -> str: + """Export sales orders to Excel with optional date range filtering""" + if filename is None: + filename = f"sales_orders_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + sales_orders = self.get_all_sales_orders() + + # Filter by date range if provided + if start_date and end_date: + sales_orders = [so for so in sales_orders if start_date <= so.order_date <= end_date] + elif start_date: + sales_orders = [so for so in sales_orders if so.order_date >= start_date] + elif end_date: + sales_orders = [so for so in sales_orders if so.order_date <= end_date] + + # Prepare data for DataFrame + data = [] + for so in sales_orders: + customer = self.customer_service.get_customer(so.customer_id) + customer_name = customer.name if customer else "Unknown" + + data.append({ + 'Order ID': so.id, + 'Customer': customer_name, + 'Order Date': so.order_date, + 'Expected Delivery': so.expected_delivery, + 'Status': so.status, + 'Total Amount': so.total_amount + }) + + # Create DataFrame and export + df = pd.DataFrame(data) + + # Ensure directory exists + os.makedirs(os.path.dirname(filename) if os.path.dirname(filename) else '.', exist_ok=True) + + df.to_excel(filename, index=False, engine='openpyxl') + return filename + + def export_sales_report(self, start_date: date = None, end_date: date = None) -> str: + """Export sales report to Excel with optional date range""" + return self.export_to_excel(start_date=start_date, end_date=end_date) + + +class InventoryService: + """Service for managing inventory""" + + def __init__(self, db_manager): + self.dao = InventoryDAO(db_manager) + self.product_service = ProductService(db_manager) + self.movement_dao = StockMovementDAO(db_manager) + + def create_inventory_record(self, product_id: int, quantity: int = 0, + reserved_quantity: int = 0) -> Optional[Inventory]: + """Create a new inventory record""" + # Verify product exists + if not self.product_service.get_product(product_id): + return None + + inventory = Inventory(product_id=product_id, quantity=quantity, + reserved_quantity=reserved_quantity) + if self.dao.create(inventory): + return inventory + return None + + def get_inventory_by_product(self, product_id: int) -> Optional[Inventory]: + """Get inventory by product ID""" + return self.dao.get_by_product_id(product_id) + + def get_all_inventory(self) -> List[Inventory]: + """Get all inventory records""" + return self.dao.get_all() + + def update_inventory(self, inventory: Inventory) -> bool: + """Update an inventory record""" + return self.dao.update(inventory) + + def delete_inventory(self, product_id: int) -> bool: + """Delete an inventory record""" + return self.dao.delete(product_id) + + def adjust_inventory(self, product_id: int, quantity_change: int, + reference_type: str = "ADJUSTMENT", reference_id: int = None) -> bool: + """Adjust inventory quantity and track movement""" + # Adjust inventory using DAO + if not self.dao.adjust_quantity(product_id, quantity_change): + return False + + # Track stock movement + if quantity_change != 0: + movement_type = "IN" if quantity_change > 0 else "OUT" + movement = StockMovement( + product_id=product_id, + movement_type=movement_type, + quantity=abs(quantity_change), + reference_type=reference_type, + reference_id=reference_id + ) + self.movement_dao.create(movement) + + return True + + def get_low_stock_products(self, threshold: int = 10) -> List[dict]: + """Get products with low stock""" + inventory_records = self.get_all_inventory() + low_stock = [] + + for record in inventory_records: + if record.quantity <= threshold: + product = self.product_service.get_product(record.product_id) + if product: + low_stock.append({ + 'product': product, + 'current_stock': record.quantity + }) + return low_stock + + def get_low_stock_items(self) -> List[dict]: + """Get low stock items""" + return self.get_low_stock_products() + + def get_current_stock(self, product_id: int) -> int: + """Get current stock for a product""" + inventory = self.get_inventory_by_product(product_id) + return inventory.quantity if inventory else 0 + + def get_stock_movements(self, product_id: int = None) -> List[StockMovement]: + """Get stock movements for a product or all products""" + if product_id: + return self.movement_dao.get_by_product_id(product_id) + else: + return self.movement_dao.get_all() + + def export_to_excel(self, filename: str = None) -> str: + """Export all inventory records to Excel""" + if filename is None: + filename = f"inventory_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + inventory_records = self.get_all_inventory() + + # Prepare data for DataFrame + data = [] + for record in inventory_records: + product = self.product_service.get_product(record.product_id) + product_name = product.name if product else "Unknown" + + data.append({ + 'Product ID': record.product_id, + 'Product Name': product_name, + 'Current Stock': record.quantity, + 'Reserved Quantity': record.reserved_quantity, + 'Available Stock': record.quantity - record.reserved_quantity + }) + + # Create DataFrame and export + df = pd.DataFrame(data) + + # Ensure directory exists + os.makedirs(os.path.dirname(filename) if os.path.dirname(filename) else '.', exist_ok=True) + + df.to_excel(filename, index=False, engine='openpyxl') + return filename + + def export_stock_movement_report(self) -> str: + """Export stock movement report to Excel""" + return self.export_stock_movements_to_excel() + + def export_inventory_report(self) -> str: + """Export inventory report to Excel""" + return self.export_to_excel() + + def export_stock_movements_to_excel(self, filename: str = None) -> str: + """Export all stock movements to Excel""" + if filename is None: + filename = f"stock_movements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + movements = self.get_stock_movements() + + # Prepare data for DataFrame + data = [] + for movement in movements: + product = self.product_service.get_product(movement.product_id) + product_name = product.name if product else "Unknown" + + data.append({ + 'Movement ID': movement.id, + 'Product': product_name, + 'Movement Type': movement.movement_type, + 'Quantity': movement.quantity, + 'Date': movement.created_at, + 'Reference Type': movement.reference_type, + 'Reference ID': movement.reference_id + }) + + # Create DataFrame and export + df = pd.DataFrame(data) + + # Ensure directory exists + os.makedirs(os.path.dirname(filename) if os.path.dirname(filename) else '.', exist_ok=True) + + df.to_excel(filename, index=False, engine='openpyxl') + return filename \ No newline at end of file diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/__pycache__/__init__.cpython-312.pyc b/src/ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c817a4b46ad38868f0ab10a54ea98609cf0b7ca8 GIT binary patch literal 156 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 UN0O(>t7F&cYxH-7*3l&W7x4r%Qvd(} literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/date_picker_dialog.cpython-312.pyc b/src/ui/__pycache__/date_picker_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e2c77399ca9cf1c6d0aa7a76b61e741e4797144a GIT binary patch literal 8293 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 literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/date_range_dialog.cpython-312.pyc b/src/ui/__pycache__/date_range_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd1e687bd67ee111d246f574279326d5b30b0237 GIT binary patch literal 10117 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 JIczf^{|VVGiOK)~ literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/main_window.cpython-312.pyc b/src/ui/__pycache__/main_window.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db42750d5d22244a1dd917faf7e6433fc4f21c2e GIT binary patch literal 70712 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

keFWV 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

{62aU2Lxy-S%2Tte_nY2H@PkJj6=r2G)$dv@A@e z1h<#77VI-35}&YN*~6BzvFJ zl#<`?;}D}TnW(8wMS`&UYgd%46yNw&y`ltg86CrYnnM@7Fr=7>ILZc`^kJFQlae9F z7eZygrko_;C?~QUD3i!e!Ysi4YjnPcP8mAHp4E5o4Xs$Quc3n+g_wwEHgf67Wkbp& zU4mbe{&=-26q zc41vTzpg%zy?rccfyxl5T%O9Eh@JG#l+147sN5iR5E9PLyYIi}pJ)kWZP8j021`_6 zE~^CXT%nyRH|LNp3faULY?^MHO+`7z1ICXJ9C_3}cN6q#XWN4ZuE^JZtmmoq^VEju zc?0VFE5#zEgboswp#c+~flUIZ)e$RZ$mfVk$I8~JB8<$EV??xRr!Yw5do+Vo0BQR& zHOE_;A^GodxG1NH^*>z1>TH;0DzIvz-U+T;!XgQXG>TMbCz?U(>=Y9^I|qiCVK=_h zot<9;6M)heb{p2T3Y}_nYS7t?4xy{9_|}QeRdk%_bfLqca|xZFqw^G<&B!o6$G3k# z=O58Ap`RHYyfU7BhJHk&@qgeO(c!|o;8~);^6U79+6ye|0kfis4yIfo03nJ)13GGd}Gx=3A>wpKi~Fx7r*_?$d2?@H5>6ZwWCFgd%V9 zPsdX##&X#xd&WRO>2p2=Y^b+ffpj5PhvvOleUHIxA$liqPY{=+2Trb~^y&6S%VFcw zeKDb|$Xl-EwDIXqvt|9$3>NZ;7J()OhSn$-PzGYe*&uT1|QfWhtE$>)|#@|8de8791$kvm2UyM_J zox|S2O|@w9jCjPDA&+Rj;q+b;WBUfd1e{llWBOQPA}r3?F9SoY?YK{hRe~HNM$6z2)8dhxP9^K5U%b^8NZp_1|xN)Cd+F z0{browq6dDUJ0ai1`SvL)?okGFc2^d{MfMJW5W^NaOB5^>K_{#er!1SW5c!|8#cp# Re{UX0HI_a#pi7GT{{yrUwFCeF literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/manufacturing_order_dialog.cpython-312.pyc b/src/ui/__pycache__/manufacturing_order_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db9832782c204491a393204f16f7dc21c90e4e88 GIT binary patch literal 7452 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 H3}XKWp8!yN literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/product_dialog.cpython-312.pyc b/src/ui/__pycache__/product_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..206126f012d9e70b24e0496ec61a33cfe8b6af0d GIT binary patch literal 5281 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 literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/purchase_order_dialog.cpython-312.pyc b/src/ui/__pycache__/purchase_order_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ecdbc2b9345d976458e3a17484d06f1907ea2570 GIT binary patch literal 15211 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 F`EL)XF0cRq literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/supplier_dialog.cpython-312.pyc b/src/ui/__pycache__/supplier_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..16136934395a97dc3125bdfe4e020771d702f4ef GIT binary patch literal 6702 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 V3rCWt$*W`6>1*_Ngf`JA{TGlsG#CH? literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/user_management_dialog.cpython-312.pyc b/src/ui/__pycache__/user_management_dialog.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f464910959eb9cece27eb9abcb7d3ee0b9f713cf GIT binary patch literal 27751 zcmeHweNY@%o>=#M@n9MTgaqP4XoQf^==%ff1Ie-x2=sxFtZyOg@i0BWpn<_XgArp` zZ(W=OCEi%BN=hh>Pxz85qq362mAhM9m*nJKr%dXSyKd9kX8W{>9lKKH`y=s6x;-!1 zROR=3Jw4OY^hm8pFFnY;U1n zVfyzyyOwUxrI0xQNge^2MBO+TAc1?>QFehnGSyJzj=M4w$)r8FK$IOlOErWxI`M zL-sD)7}Le_w%53J2XB9kX?OAth+Q~^?K!*)V#32Dwde8#;yfOvq20~rLCo_4XQ?|vODf`7qYW8#=g!xj4o^8;8UBkX(d4g<@K*hUz?O?zTsdB9Vuwr+fK z=*bwWxB58M85zLftc!yhvSiuO1eYQzEE5kR&(P*pg$IdPYRhrpwk}< zK?T3s>KP19OMHyx@2>h4lq0|5aFYpv({{lQ2e7W4aK_`SSd@Au~q z^YtAA!||SIe=y32I{Kiu{?0%w9K>Za*cAxI2ZfM7Ffh;&6M`Ls;STJ0a!KJz^Ye76 zZ5|jF@T(?PSS`fO`Yhli#D*aH`uogXl7A&fBFo21Wa6ExTsP*BN!i?rRrp&zx03!= zeoDAE@~-5G#WgZnGY1d(SMslhp}d2KADU)@$z_dc#sz=kNsT%zZDp#aLq>t?Eo|wNcxM&9crs9&2^q;wzB(Nv4$HYIqpP!g5v$X|S3J?x=`gL%)QW`pS-Nn3EJv~U)#*s#0_TX+vd+b> zPA6XpJuX?;<0raqocs!CW%;kYl`3eZ{0X*VwK~)I*frwH@(mChKR$+V@hhRZVTWcep0#X-Y zO%ga3=0n1I%-Mj^Mu=1=vMMp3U04ZEUm_A0R$&T>(U%*65dg;D)e{PL_rz89@=d!| z9*fe3=1Bw3d$Eq2Heog7syV@EA8<`D2w^iqA?SSWK|UO(UQ3Rry&*#clUeaQ4KUve5N0GJIv}5qJ3JX>THZe zV<8X_?7{dYl{B8d)EejvMR2}?+Hzg_E6AGhmtOgC1{?SM~i4_E?Gu<@@W0{$D>Km z;=$yQ{5Yg!W)2^U#f9jwFGoP615#Tc+z$Xa47b*0?O_Q%_&aHrV@Uum*)I*Em_C_7 zlD(iHG2jZ3QN|!t;FrSXmxasK+43>~i7f%H^83se#Qg?$g}ZuM^llS(ACpO&LQYBK zlz8EyNKVP5h$h+#)l@)PB+~i5=og%j_dQ%F-gQ-tuRpvIVq8o;`s|AIVqEN zsAgrgQdK8a)lGHGtd^_Va9%2?+)yEr3Wazj;+^Q4I(^$VvqU6bnY7X}HzL;~llx|? zc8dtD!v&{o(?t|&Z)#qAQ|{ZRKYroE7i4l;Ar~ZaLG*{jNS{b9$fQ3tVBZvXoBufa zVN@n(6>?D`7sWug*dGFh5WhtHV(5}6#6;qkNjyC-O|zvw zA8veqqfDMrNRvdGW}wxkJLH&vlrTd8 z!wp6SFcARO43iT5ejnf?6LAU224eZ-z{VDfA-hHw73|9<*rJ=ZA$= z@p`_48HSl=;cwJ6#Q2uC33X6T*ap#;lp}cxQmQQsEK5I7tc1TSPwe{>JRpWBRnYQgy#@IQxA5B;{HG+LT53ia+HA?3iFU1 z6B-b#38O}=#e=XEKwB}YqcQlg<`4$f7Co$ zgMoR{m@Lpl(ppuw#v$Q~0}Z3rlAki@d313m)3g>~9Yc|r8#?eJvP)Xfm?lIliIry_ z{>6_2Q0yYCHu%1umXs?c>!gx(O35y%WYpaZBE4yV7=ttNhB&l-7d=uxOc5 zxJoKqr4(+F3b)J_ZX0vXRaQ|&;ulNH#+vSyRo8EB9Tm$D{B&Q-_<`~Gjp6IVlNGZopAxHfP6ed$-7_zY9i6M)FnQ#Y+FjSn zr%#R@`6Ahc-szg@gj9b-+;ep1$jm~ zFJTAwKnL?nvD4$NQ@p$$vQ8vNkFxaAwl8*WX8K=F2zG zFyR8M9|38Ya1o-LETukaN+a<23NiJ!B`Xvk17YEkgoWZBAY_-IZk6p`NLw@{y*)dU z(XiJ-x{`nz@8gF;y22~_k4yWHizl9w_n&-%gyl7;U-(B5SrC?Q(C1gMx-pC>W#NRQ zm>$3=O->4Qa-xk~&8NS9Yl1>4hcJjWybO`gMQ2#R!y474RUnMuLy{yUX@*v%hWZh= zyhzc$hV_vVOi*oyHJ#ULrPTIw0M*gHPb%>#CA+1P-9IU*H>3T%fDD(EC`Hv$QMFRE zO)AWg`Ch=vT&coUN?hf{>dA(w z29c|jxt(7m0biM3HvRGkD`dp@X~4Bqz(qi$HTlc6^5Y#-gYt%xGIuHkna-kExJu%x z6mE^gt(kab>gerd;@Xxw+@YVBG9{G@7ZftThzRb_L39lcUEwqYQd$6Qn~|$Y=7P9$ znqfMi)TnLLJ_=&Q2zzNAe77}O&U@Hfsgn;tHr6K}C~zbhJrI<(WvR*e7&W;vYC4N3 zX?hT$>G5;ZWiuL4vI`MB>h z`T^yqyY?!(4#~R?N!*UH*71##?m18SIRA;KdakfiDO@WRuAOp;g=^))UFnQk$hcEj z|7i*$pF-?k0P*vDX7^!MTP;TR8&?BeEj5sU)xuaY=epp^GGN_cOYAxs>Jmb+9&fxS z{m^t^YLTE- zsqvp!U1F^z-dzCqPp~G`U;~S%ozUJPW#7?VAzX!d@Y&S@ZKZ)x07ZzxKZR`J8oo=e za74SpI7ZhYO09JbeVP{ZKLm)xD$9>yB&7cg!1NVewVLX?KHa@f*?mObeMI8w=!)j% zJQd@EpLlB0%kx{U=sia*R&)yg;qEq`zmMSRw!nTV_6jhi)d@{nG^J)Bs9rJ!!~S3> zmjR~<`Ji&gNz=qoO&W6#1wgkA!WthP(sqK4?gZ7P**wIsce+~m5TvuHg>_{)e!Li- z34rZGQPp^S; zYznAk(q|Iz1QSoq6PBF&eMV@9gqm||Fc<`z#CaF4QE%;hNv&6d=b<9{s%tP38BQ!~4um5>#9@CJ z>hqEGHW4PVLK28w8V<$!qZGplM`#}IAJj-`o8(_Xo`Kd=m@FKU))Zv|a8hDFgecoC zIU;kwS}*y+y-aaeN$x6fOR|;24g{vnHPnAqNfO1K@kId9dyU&Wd&WY#x z#4Y`DVRUYd@2W#C1fFc^HP4%#JB6#hz;ZQGVa>!jar41Dh0R~(Gu-!ndkIqk5T+J9 zOEpj?4W*%UwGgGXLT(n6mO6s{Bm7vK>BqWj#9FgOyDhL)%6M)eJ8B|DX=67ul+$<4 zBS{gQcIdE=O^WiM_ok3RiYy3?i4;8oGx!{B!VPFa+wLpO+nw@WxZNl@B5F=gAQp(n z1p$$*w%0sJj23)4S|=q**5B_Kl+U6mr6h?q`~d*P&Oij5gs0?cTwJk9_H53eN{3|* zRGmPTyc0`N5Z*1-?w+nu>W@kF$7YM#7WbV~bm{jgU3x;_`QdcQ!8@&Kjdpqkm_H|t z`GtP~uoi2=%F;MXtt5jkH5n)avKpqq=Yqb?k`ktR=T7$y4E$1hK;zDMw4E|AX6ae7 zqEK-DX52fPwl2MHS7u$e8txX57$#~TMVz$NUQ1Pe!YwF5$&>JJAvK>vm$w=zWM(Fz<-aokaJCnHnKn7V#!Yjc9v60%+DkT^6fZcE7SR}2JcljPYn$t#;1q|FVt zYoyIBvgeSIAeAEj*DCF4ojEJ*IW6u!Bc8b=Zi&c+eRm5>#~otTX1Q<+&?KeMD;0Y0 z6jo*=_kn{8kqkyE`B85W_gEk?OE@KMR10cqk~$k_(1Jc zJ1GtdhYY*IYIlP1CDa4cWDY#t<#>{vrJX#)0`T7bwlh68fKBb+bd8JRBQtg>YSfWo znqO+(o`b;V(OogW%>0V3>-4@8{vA|6*~P@NTCZlZq%nXL5cVVL=T){gQI+13N&7Ht z1!=$bOg(zYyaRN-xm%=_~=WF~4XB`T9~*Ixo;?57ZA;~1Zn zxdz;zSqNN%%pHK&pSnGYyHavjD(=mad-G&m+0r0w!CkqfWoD^zxI;SJAwD0F4tL7# zAf=EiC2r+J?j)EwDRs?KUGwdPRCiL`aY{VZBZm8w@Q4&1fg`!^u}!w0GoWBQXoIIP z+l)WWwif)^cnDAH@(HkdfehxK1>7W%1fenZs6z}DbONH@gwG%ZY)B;BM7keI}uU})nvU!{plNbMn zfuDQTbSf&{P=!Iut4e9s5p4;1modBZd)Pbwu zj;&j75&`)gZ51JX5DGCuenUV*0|BkEwB=O_sg&cMwjQuIi4sr|;66^8Q0cyQ{|CWw zS$&ceOt4K=&A~bwBVdq-!ZXKpV&?MkT(L0{>e%(Iq)R7*#4jeXd(MI=5zZ zB!F5(KJS)W*AQelT%l}h3Q!|=D_^hCg5=8-!0j=)CLgC2 zK$zui2@VjPw=Fb8O-TgwauTXBT7%JAh~{$<{~=MX7k&>j0~q-*+6NI>4Fxg15u;j+ zwqq^}76@TJ-b0OZ6S*fqKpBb(e7(x92lt`B01$AZ2Hkj~NLS{kK<4wPMK0}-?)^hv z4uCTgc+aSgQ-PO5!Y*uR6GnTmDpw;|<%S|)1loXk2QX^H7wD985L~o})q=s~DE#QM z4bKC~E+wB!sO^K$=Qri&9f(HZw5>Pf?CzA~B-MaDrHE=x0=(Y;-M1@B^i2Va}W^AMo^ zFcWT>Y*ffLiENuLzP){>MI_t6IpS{BD(Y2HHx-ks_FDR31gLMtx<%D>yI8I|_?Wec z-W@WjQ^+2P?73Y$vt2w5t~z_bRp;(n-@B1_B2#tKv6*6d?J=xxH9faoEs@mp&It6RDTrQNEGs%M4l7RfhWpynkP!q zp~dXD0lpaE%2J(j+L$bvUixQMA5_WYkV1}0z9$%69ztFh>llEv+L}dP1B+;Lu7kJgh$OV7FAVKC}#6Uty^V zPNE&XUHnb;4J@cY-Ie0)teiQ_C*~uz$0(6;umP87*e?NA5FXu9hGH!jEg>vOjMWtv zU_WLXF}RKx#$-WScqi`~VKY_fR4I+DrYY!ig-0~2nK&X08fpNqjyetPSP)j2I})$Z zON^xMM*t@fgB8LBIKCZqe$P3a{2gM4n9KGH%n<9#O)AVvlUgyrkD$WRi~gxVJA55B8)5bv#oI3L$C(m2sw8nik8o_f#ma_W}l2U?{2`tSjM~ zkGK#X5au95KxLNjR~X?^qlZ9ALlUjLhH$Cw>^T5Xa}Qn)hUgtLsvWA)EiV1|j_3nW z3(ZJhK(UXS!BS&xYRs1OP5c?WNt9-a11VHr4k$WSM)CAqaj8&D2!myibr@)bL!ik(VDy;M;@Td@yZ4k=)bR9vGJuak<`%@(g8BcFLn zGmfDeWv&UdbXl@Lbr*}JwV$|aGs+y0xkf1SmBX3a#!?NNX4meMIN#*DsYBB}GS`x+ z95^&pFFt)r+;Cdv&OnJbTCcSJpzZ6sp5^x#7Ai4Tzp?S7#u@g<&9`@p>suA>n8Y3X zzhBuAYCeX_-ygdga+(U5j|y@R+U*~eRyXapf3)8TX^_-D#F_sQMnA^LB>v;<3m;+h zpCK}e?SFuo82xX2fg6E}?QZ9C6x-qFUU{W6$o^tqDG2WrFH`6s0Hy-2DGc9K=)MVM zHK7}1v{b-a@$dfsg2*IvgI&nO?*SSCRGYpB2#}POfStOIgd!Gqs^wI|B!Z`qa0(#v zCZb1(*w716w{%qS>0Rn_2%zzwN+ zJ83uKl;SzCg?#=L?#FXbAe($Xw{-cKeN*5)_<7Uf4nU;2q;Llw7l@F;PY{SQC9ZHk$dDjBVvdRtWk~K~stF^M{{%{@kOvW{ULX6T z+p$tf&y2EkQLhnE)?SE=qMlpa-eM3i&QKZT?4rao3sJ9HBGnVyCir*5?}VrFq_u$k z_L)8M(~_VjYfUZvVcGj-)AiD}X1TKE?~K@m^qA6M7tHK$iP(kWP>PyciBo2A$N+7H zPQl7F1DQTbP43hfe&I8i8`W_btP>MWNJEqE3f`{4{-Aa{V>mwS9f}S{cyDLO8}1v3 zgz#EAIBg5}djkL$iFm;eHWCg75FSh-ai0jEWAp_^CWife%)_k#UMDEy&?9!bMU#$O zMpk>diL|G9?en-%x}Z25mpzi#emoZX0bz3C+7G;tV!T4p0=kQ)GH$`1-OmhrE# zyJpstvV9oUVV96ep{ysTv7YdAud*0f&-HF+sa6dF2jMRjaBX7vrkv-$hqB+{h0sq6%@y4Jm_#a;9P|?Cb8(tn z7>-A*TfsXR@vPJo7S{^!cFWol=m$iP8t7KE;_~L*^p&?Q+i8QD+uH`+ny)$$68ge$ zJy*0p=3Phc2?N&_aC1bxldnEIua{>!E}*=gnnw)~v{Qm2xMxQeGBSJ~ai_KJ8gBp5bo==-*{qNq64^1m)nSFSNu*6Y4GngR z1g@zHep8(~YP@l+-~ScN;%@_UXSL&W^$6(O7tQ#M*n~~sEo`B-0aH>KGX}VV$ez4{ z2=-~V+gJ5oz{NyxQ4?IC1D6eTGvMEBdBdueng_JsDK=BsU|>ltumjxm>ZH1FgL@NI zQK8SPDd;_;?g_d2ODhZvY;npI0ilyu0omayAd=K!mzi|fbaQ%fL#ymYO)otSO$h%J zBBMQZQVC7L;ZB#Y5$1D@C>#4rOyNS)oDZBaV0n|*K66R+vxWuI3~ti9^db~e-3BD0 zvIAzw!Fw|b`jaRR{s(7j;PxY*)M)9ndJnLsY za4t57x4dkWN;WDbTcwh%vnAU#zyDeA`*(llDK_eD*B_KQyaML4BWEUGQMNoQZFv?> z^|l;zkPe>W8!b2B`Bnmq#=U%n z1u4ReV~VmcO_-ueYDzv0nmE;CL6Ortz5E-QzPTX`o zt&I2(OY}jy)1Ghj=UV?h^ttmY@0{aTo z{NP|L9_@=?3ZqRDMRsbA{)B5E^_$4dxYR-{BN14^$0c4JROBYiS&=kN1zT~pi%<1a#OqIjhg(!4XT^!s(yyC_koMI zvnSZ`9XFo2{>%gT553^wL0e8E%eJvs8?POI^Y{aXK0Ii(ZDC8Vp1S6L)Bk{h days_in_month: + # Empty cell + ctk.CTkLabel(self.calendar_frame, text="", width=30, height=25).grid( + row=row, column=weekday, padx=2, pady=2) + else: + # Day button + day_date = self.current_date.replace(day=day) + is_selected = (self.selected_date and day_date == self.selected_date) + is_today = (day_date == date.today()) + + btn = ctk.CTkButton( + self.calendar_frame, + text=str(day), + width=30, + height=25, + fg_color="green" if is_selected else ("#2CC985" if is_today else "transparent"), + text_color="white" if is_selected or is_today else "black", + hover_color="#2CC985", + command=lambda d=day_date: self.select_date(d) + ) + btn.grid(row=row, column=weekday, padx=2, pady=2) + day += 1 + + def select_date(self, date_obj): + self.selected_date = date_obj + self.display_calendar() + + def select_today(self): + self.selected_date = date.today() + self.current_date = self.selected_date + self.display_calendar() + + def prev_month(self): + if self.current_date.month == 1: + self.current_date = self.current_date.replace(year=self.current_date.year - 1, month=12) + else: + self.current_date = self.current_date.replace(month=self.current_date.month - 1) + self.display_calendar() + + def next_month(self): + if self.current_date.month == 12: + self.current_date = self.current_date.replace(year=self.current_date.year + 1, month=1) + else: + self.current_date = self.current_date.replace(month=self.current_date.month + 1) + self.display_calendar() + + def ok(self): + if not self.selected_date: + self.selected_date = self.current_date + self.destroy() + + def cancel(self): + self.selected_date = None + self.destroy() + + def get_selected_date(self): + return self.selected_date \ No newline at end of file diff --git a/src/ui/date_range_dialog.py b/src/ui/date_range_dialog.py new file mode 100644 index 0000000..5ea426c --- /dev/null +++ b/src/ui/date_range_dialog.py @@ -0,0 +1,145 @@ +import customtkinter as ctk +from tkinter import messagebox +from datetime import datetime, date +from typing import Optional, Tuple + +class DateRangeDialog(ctk.CTkToplevel): + def __init__(self, parent, title="Select Date Range", ok_button_text="OK"): + super().__init__(parent) + self.title(title) + self.geometry("350x250") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + # Initialize variables + self.start_date = date.today().replace(day=1) # First day of current month + self.end_date = date.today() + self.ok_button_text = ok_button_text + + self.setup_ui() + + def setup_ui(self): + # Title + ctk.CTkLabel(self, text="Select Date Range", font=("Arial", 16, "bold")).pack(pady=10) + + # Start date + start_frame = ctk.CTkFrame(self) + start_frame.pack(fill="x", padx=20, pady=10) + + ctk.CTkLabel(start_frame, text="Start Date:").pack(anchor="w") + date_frame1 = ctk.CTkFrame(start_frame) + date_frame1.pack(fill="x", pady=5) + self.start_date_entry = ctk.CTkEntry(date_frame1, width=150) + self.start_date_entry.insert(0, self.start_date.strftime("%Y-%m-%d")) + self.start_date_entry.pack(side="left") + ctk.CTkButton(date_frame1, text="...", width=30, command=self.select_start_date).pack(side="left", padx=(5, 0)) + + # End date + end_frame = ctk.CTkFrame(self) + end_frame.pack(fill="x", padx=20, pady=10) + + ctk.CTkLabel(end_frame, text="End Date:").pack(anchor="w") + date_frame2 = ctk.CTkFrame(end_frame) + date_frame2.pack(fill="x", pady=5) + self.end_date_entry = ctk.CTkEntry(date_frame2, width=150) + self.end_date_entry.insert(0, self.end_date.strftime("%Y-%m-%d")) + self.end_date_entry.pack(side="left") + ctk.CTkButton(date_frame2, text="...", width=30, command=self.select_end_date).pack(side="left", padx=(5, 0)) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.pack(fill="x", padx=20, pady=20) + + ctk.CTkButton(button_frame, text="This Month", command=self.set_this_month).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Last Month", command=self.set_last_month).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text=self.ok_button_text, command=self.ok).pack(side="right", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.cancel).pack(side="right", padx=5) + + def select_start_date(self): + """Open date picker for start date""" + try: + current_date = datetime.strptime(self.start_date_entry.get(), "%Y-%m-%d").date() + except ValueError: + current_date = date.today() + + from src.ui.date_picker_dialog import DatePickerDialog + dialog = DatePickerDialog(self, "Select Start Date", current_date) + self.wait_window(dialog) + + selected_date = dialog.get_selected_date() + if selected_date: + self.start_date_entry.delete(0, 'end') + self.start_date_entry.insert(0, selected_date.strftime("%Y-%m-%d")) + + def select_end_date(self): + """Open date picker for end date""" + try: + current_date = datetime.strptime(self.end_date_entry.get(), "%Y-%m-%d").date() + except ValueError: + current_date = date.today() + + from src.ui.date_picker_dialog import DatePickerDialog + dialog = DatePickerDialog(self, "Select End Date", current_date) + self.wait_window(dialog) + + selected_date = dialog.get_selected_date() + if selected_date: + self.end_date_entry.delete(0, 'end') + self.end_date_entry.insert(0, selected_date.strftime("%Y-%m-%d")) + + def set_this_month(self): + """Set date range to this month""" + today = date.today() + self.start_date = today.replace(day=1) + self.end_date = today + + self.start_date_entry.delete(0, 'end') + self.start_date_entry.insert(0, self.start_date.strftime("%Y-%m-%d")) + self.end_date_entry.delete(0, 'end') + self.end_date_entry.insert(0, self.end_date.strftime("%Y-%m-%d")) + + def set_last_month(self): + """Set date range to last month""" + today = date.today() + if today.month == 1: + self.start_date = today.replace(year=today.year - 1, month=12, day=1) + self.end_date = today.replace(year=today.year - 1, month=12, day=31) + else: + self.start_date = today.replace(month=today.month - 1, day=1) + # Get last day of last month + if today.month == 1: + self.end_date = today.replace(year=today.year - 1, month=12, day=31) + else: + first_day_this_month = today.replace(day=1) + self.end_date = first_day_this_month - date.resolution + + self.start_date_entry.delete(0, 'end') + self.start_date_entry.insert(0, self.start_date.strftime("%Y-%m-%d")) + self.end_date_entry.delete(0, 'end') + self.end_date_entry.insert(0, self.end_date.strftime("%Y-%m-%d")) + + def ok(self): + """Validate and close dialog""" + try: + self.start_date = datetime.strptime(self.start_date_entry.get(), "%Y-%m-%d").date() + self.end_date = datetime.strptime(self.end_date_entry.get(), "%Y-%m-%d").date() + + if self.start_date > self.end_date: + messagebox.showerror("Error", "Start date must be before end date") + return + + self.destroy() + except ValueError: + messagebox.showerror("Error", "Please enter valid dates in YYYY-MM-DD format") + + def cancel(self): + """Cancel and close dialog""" + self.start_date = None + self.end_date = None + self.destroy() + + def get_date_range(self) -> Tuple[Optional[date], Optional[date]]: + """Return the selected date range""" + return self.start_date, self.end_date \ No newline at end of file diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..a27ab0c --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,1218 @@ +import customtkinter as ctk +import tkinter as tk +from tkinter import messagebox +import sys +import os + +# Add the project root to the path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from src.auth import AuthManager +from src.services import ManufacturingOrderService, PurchaseOrderService, SalesOrderService, InventoryService +from src.database import DatabaseManager +from src.services import ProductService, CustomerService, SupplierService +from src.ui.product_dialog import ProductDialog +from src.ui.purchase_order_dialog import PurchaseOrderDialog +from src.ui.manufacturing_order_dialog import ManufacturingOrderDialog +from src.ui.sales_order_dialog import SalesOrderDialog +from src.ui.customer_dialog import CustomerDialog +from src.ui.supplier_dialog import SupplierDialog + +class MainWindow: + def __init__(self, root, user, db_manager, app=None): + self.root = root + self.user = user + self.db_manager = db_manager + self.app = app + self.auth_manager = AuthManager(db_manager) + + # Configure the main window + self.root.title("Manufacturing Management System") + self.root.geometry("1200x800") + ctk.set_appearance_mode("light") # Options: "light", "dark", "system" + ctk.set_default_color_theme("blue") # Options: "blue", "green", "dark-blue" + + # Initialize services + self.manufacturing_service = ManufacturingOrderService(self.db_manager) + self.purchase_service = PurchaseOrderService(self.db_manager) + self.sales_service = SalesOrderService(self.db_manager) + self.inventory_service = InventoryService(self.db_manager) + self.product_service = ProductService(self.db_manager) + self.customer_service = CustomerService(self.db_manager) + self.supplier_service = SupplierService(self.db_manager) + + # Create the main interface + self.create_menu() + self.create_toolbar() + self.create_content_area() + self.create_status_bar() + + # Show dashboard by default + self.root.after(100, self.show_dashboard) + + def create_menu(self): + """Create the main menu using tkinter's native menu system""" + menubar = tk.Menu(self.root, bg="#f0f0f0", fg="black") + self.root.config(menu=menubar) + + # File menu + file_menu = tk.Menu(menubar, tearoff=0, bg="#f0f0f0", fg="black") + menubar.add_cascade(label="File", menu=file_menu) + file_menu.add_command(label="Logout", command=self.logout) + file_menu.add_separator() + file_menu.add_command(label="Exit", command=self.root.quit) + + # Modules menu + modules_menu = tk.Menu(menubar, tearoff=0, bg="#f0f0f0", fg="black") + menubar.add_cascade(label="Modules", menu=modules_menu) + modules_menu.add_command(label="Dashboard", command=self.show_dashboard) + modules_menu.add_separator() + modules_menu.add_command(label="Purchase", command=self.show_purchase) + modules_menu.add_command(label="Manufacture", command=self.show_manufacturing) + modules_menu.add_command(label="Sales", command=self.show_sales) + modules_menu.add_command(label="Inventory", command=self.show_inventory) + modules_menu.add_separator() + modules_menu.add_command(label="Suppliers", command=self.show_supplier_list) + modules_menu.add_command(label="Customers", command=self.show_customer_list) + + # Configuration menu (admin only) + if self.user['is_admin']: + config_menu = tk.Menu(menubar, tearoff=0, bg="#f0f0f0", fg="black") + menubar.add_cascade(label="Configuration", menu=config_menu) + config_menu.add_command(label="User Management", command=self.manage_users) + + # Reports menu + has_report_permissions = ( + self.auth_manager.user_has_permission(self.user['id'], 'view_reports') or + self.auth_manager.user_has_permission(self.user['id'], 'view_inventory_report') or + self.auth_manager.user_has_permission(self.user['id'], 'view_stock_movements') or + self.user['is_admin'] + ) + + if has_report_permissions: + reports_menu = tk.Menu(menubar, tearoff=0, bg="#f0f0f0", fg="black") + menubar.add_cascade(label="Reports", menu=reports_menu) + if self.auth_manager.user_has_permission(self.user['id'], 'view_inventory_report') or self.user['is_admin']: + reports_menu.add_command(label="Inventory Report", command=self.show_inventory_report) + if self.auth_manager.user_has_permission(self.user['id'], 'view_reports') or self.user['is_admin']: + reports_menu.add_command(label="Sales Report", command=self.show_sales_report) + reports_menu.add_command(label="Purchase Report", command=self.show_purchase_report) + reports_menu.add_command(label="Manufacturing Report", command=self.show_manufacturing_report) + if self.auth_manager.user_has_permission(self.user['id'], 'view_stock_movements') or self.user['is_admin']: + reports_menu.add_command(label="Stock Movement Report", command=self.show_stock_movement_report) + + def create_toolbar(self): + """Create the toolbar""" + toolbar = ctk.CTkFrame(self.root, height=60, fg_color="#e0e0e0") + toolbar.pack(fill="x", padx=5, pady=5) + + # Navigation buttons + button_frame = ctk.CTkFrame(toolbar, fg_color="transparent") + button_frame.pack(side="left", padx=10, pady=10) + + if self.auth_manager.user_has_permission(self.user['id'], 'view_dashboard') or self.user['is_admin']: + ctk.CTkButton(button_frame, text="Dashboard", command=self.show_dashboard, + width=100, height=32).pack(side="left", padx=5) + + ctk.CTkButton(button_frame, text="Purchase", command=self.show_purchase, + width=100, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Manufacture", command=self.show_manufacturing, + width=100, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Sales", command=self.show_sales, + width=100, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Inventory", command=self.show_inventory, + width=100, height=32).pack(side="left", padx=5) + + # User info + user_frame = ctk.CTkFrame(toolbar, fg_color="transparent") + user_frame.pack(side="right", padx=10, pady=10) + + user_label = ctk.CTkLabel(user_frame, text=f"User: {self.user['username']}", + font=("Arial", 12, "bold")) + user_label.pack(side="right", padx=10) + + def create_content_area(self): + """Create the main content area""" + self.content_frame = ctk.CTkFrame(self.root) + self.content_frame.pack(fill="both", expand=True, padx=10, pady=10) + + def create_status_bar(self): + """Create the status bar""" + self.status_bar = ctk.CTkLabel(self.root, text="Ready", anchor="w", height=30) + self.status_bar.pack(fill="x", side="bottom", padx=10, pady=(0, 10)) + + def clear_content(self): + """Clear the content frame""" + for widget in self.content_frame.winfo_children(): + widget.destroy() + + def show_dashboard(self): + """Show the dashboard""" + self.clear_content() + self.status_bar.configure(text="Dashboard") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Dashboard", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main dashboard frame + dashboard_frame = ctk.CTkFrame(self.content_frame) + dashboard_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Welcome message + welcome_text = f"Welcome, {self.user.get('full_name', self.user.get('username', 'User'))}!" + welcome_label = ctk.CTkLabel(dashboard_frame, text=welcome_text, + font=("Arial", 16)) + welcome_label.pack(pady=(20, 10)) + + # Quick stats frame + stats_frame = ctk.CTkFrame(dashboard_frame) + stats_frame.pack(fill="x", padx=20, pady=10) + + # Get real data for stats + pending_pos = len(self.purchase_service.get_pending_purchase_orders()) + active_mos = len(self.manufacturing_service.get_active_manufacturing_orders()) + pending_sos = len(self.sales_service.get_pending_sales_orders()) + low_stock_items = len(self.inventory_service.get_low_stock_items()) + + # Create stats grid + stats_grid = ctk.CTkFrame(stats_frame) + stats_grid.pack(pady=20) + + stats = [ + ("Pending Purchase Orders", str(pending_pos)), + ("Active Manufacturing Orders", str(active_mos)), + ("Pending Sales Orders", str(pending_sos)), + ("Low Inventory Items", str(low_stock_items)) + ] + + for i, (label, value) in enumerate(stats): + frame = ctk.CTkFrame(stats_grid, fg_color="transparent") + frame.grid(row=0, column=i, padx=20, pady=10) + + ctk.CTkLabel(frame, text=label, font=("Arial", 12)).pack() + ctk.CTkLabel(frame, text=value, font=("Arial", 20, "bold")).pack() + + # Financial summary + financial_frame = ctk.CTkFrame(dashboard_frame) + financial_frame.pack(fill="x", padx=20, pady=10) + + # Get financial data + total_revenue = self.sales_service.get_total_revenue() + total_costs = self.purchase_service.get_total_costs() + self.manufacturing_service.get_total_costs() + profit = total_revenue - total_costs + + fin_grid = ctk.CTkFrame(financial_frame) + fin_grid.pack(pady=20) + + financial_stats = [ + ("Total Revenue", f"Rp{total_revenue:,.0f}".replace(",", "."), "green"), + ("Total Costs", f"Rp{total_costs:,.0f}".replace(",", "."), "red"), + ("Net Profit", f"Rp{profit:,.0f}".replace(",", "."), "green" if profit >= 0 else "red") + ] + + for i, (label, value, color) in enumerate(financial_stats): + frame = ctk.CTkFrame(fin_grid, fg_color="transparent") + frame.grid(row=0, column=i, padx=20, pady=10) + + ctk.CTkLabel(frame, text=label, font=("Arial", 12)).pack() + ctk.CTkLabel(frame, text=value, font=("Arial", 16, "bold"), + text_color=color).pack() + + # Recent activities + activity_frame = ctk.CTkFrame(dashboard_frame) + activity_frame.pack(fill="both", expand=True, padx=20, pady=10) + + ctk.CTkLabel(activity_frame, text="Recent Activities", + font=("Arial", 16, "bold")).pack(pady=(10, 5)) + + # Create scrollable frame for activities + activity_scroll = ctk.CTkScrollableFrame(activity_frame, height=200) + activity_scroll.pack(fill="both", expand=True, padx=10, pady=10) + + activities = self.get_recent_activities() + if activities: + for activity in activities: + ctk.CTkLabel(activity_scroll, text=activity, + font=("Arial", 11)).pack(anchor="w", pady=2) + else: + ctk.CTkLabel(activity_scroll, text="No recent activities", + font=("Arial", 11)).pack(pady=20) + + # Reports section + reports_frame = ctk.CTkFrame(dashboard_frame) + reports_frame.pack(fill="x", padx=20, pady=10) + + ctk.CTkLabel(reports_frame, text="Quick Reports", + font=("Arial", 16, "bold")).pack(pady=(10, 5)) + + reports_grid = ctk.CTkFrame(reports_frame) + reports_grid.pack(pady=10) + + report_buttons = [] + + if self.auth_manager.user_has_permission(self.user['id'], 'view_inventory_report') or self.user['is_admin']: + report_buttons.append(("Inventory Report", self.show_inventory_report)) + + if self.auth_manager.user_has_permission(self.user['id'], 'view_reports') or self.user['is_admin']: + report_buttons.extend([ + ("Sales Report", self.show_sales_report), + ("Purchase Report", self.show_purchase_report), + ("Manufacturing Report", self.show_manufacturing_report), + ("Stock Movement Report", self.show_stock_movement_report) + ]) + + for i, (text, command) in enumerate(report_buttons): + ctk.CTkButton(reports_grid, text=text, command=command, + width=150, height=32).grid(row=0, column=i, padx=5, pady=5) + + def show_purchase(self): + """Show purchase management""" + self.clear_content() + self.status_bar.configure(text="Purchase Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Purchase Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + purchase_frame = ctk.CTkFrame(self.content_frame) + purchase_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(purchase_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="New Purchase Order", + command=self.new_purchase_order, width=150, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="View Purchase Orders", + command=self.view_purchase_orders, width=150, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Manage Suppliers", + command=self.show_supplier_list, width=150, height=32).pack(side="left", padx=5) + + # Show purchase orders by default + self.show_purchase_orders() + + def show_manufacturing(self): + """Show manufacturing management""" + self.clear_content() + self.status_bar.configure(text="Manufacturing Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Manufacturing Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + manufacturing_frame = ctk.CTkFrame(self.content_frame) + manufacturing_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(manufacturing_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="New Manufacturing Order", + command=self.new_manufacturing_order, width=180, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="View Manufacturing Orders", + command=self.view_manufacturing_orders, width=180, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Production Planning", + command=self.production_planning, width=180, height=32).pack(side="left", padx=5) + + # Show manufacturing orders by default + self.show_manufacturing_orders() + + def show_sales(self): + """Show sales management""" + self.clear_content() + self.status_bar.configure(text="Sales Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Sales Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + sales_frame = ctk.CTkFrame(self.content_frame) + sales_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(sales_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="New Sales Order", + command=self.new_sales_order, width=150, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="View Sales Orders", + command=self.view_sales_orders, width=150, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Manage Customers", + command=self.show_customer_list, width=150, height=32).pack(side="left", padx=5) + + # Show sales orders by default + self.show_sales_orders() + + def show_inventory(self): + """Show inventory management""" + self.clear_content() + self.status_bar.configure(text="Inventory Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Inventory Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + inventory_frame = ctk.CTkFrame(self.content_frame) + inventory_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(inventory_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="View Inventory", + command=self.view_inventory, width=150, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Stock Adjustment", + command=self.stock_adjustment, width=150, height=32).pack(side="left", padx=5) + + # Show inventory by default + self.show_inventory_management() + + def get_recent_activities(self): + """Get recent activities from all modules""" + activities = [] + + # Get recent purchase orders + recent_pos = self.purchase_service.get_recent_purchase_orders(limit=3) + for po in recent_pos: + supplier = self.supplier_service.get_supplier(po.supplier_id) + supplier_name = supplier.name if supplier else "Unknown Supplier" + activities.append(f"Purchase Order #{po.id} created for {supplier_name}") + + # Get recent manufacturing orders + recent_mos = self.manufacturing_service.get_recent_manufacturing_orders(limit=3) + for mo in recent_mos: + product = self.product_service.get_product(mo.product_id) + product_name = product.name if product else "Unknown Product" + activities.append(f"Manufacturing Order #{mo.id} started for {product_name}") + + # Get recent sales orders + recent_sos = self.sales_service.get_recent_sales_orders(limit=3) + for so in recent_sos: + customer = self.customer_service.get_customer(so.customer_id) + customer_name = customer.name if customer else "Unknown Customer" + activities.append(f"Sales Order #{so.id} created for {customer_name}") + + return activities + + def logout(self): + """Logout the current user""" + if messagebox.askyesno("Logout", "Are you sure you want to logout?"): + # Use the app's restart method if available + if self.app: + self.app.restart() + else: + self.root.quit() + + # Module command methods + def new_purchase_order(self): + """Create a new purchase order""" + dialog = PurchaseOrderDialog(self.root, self.purchase_service, self.product_service, self.supplier_service) + dialog.grab_set() + + def view_purchase_orders(self): + """View all purchase orders""" + self.show_purchase_orders() + + def manage_suppliers(self): + """Manage suppliers""" + dialog = SupplierDialog(self.root, self.supplier_service) + dialog.grab_set() + + def new_manufacturing_order(self): + """Create a new manufacturing order""" + dialog = ManufacturingOrderDialog(self.root, self.manufacturing_service, self.product_service, self.inventory_service) + dialog.grab_set() + + def view_manufacturing_orders(self): + """View all manufacturing orders""" + self.show_manufacturing_orders() + + def production_planning(self): + """Production planning functionality""" + messagebox.showinfo("Info", "Production Planning functionality will be implemented") + + def new_sales_order(self): + """Create a new sales order""" + dialog = SalesOrderDialog(self.root, self.sales_service, self.product_service, self.customer_service) + dialog.grab_set() + + def view_sales_orders(self): + """View all sales orders""" + self.show_sales_orders() + + def manage_customers(self): + """Manage customers""" + dialog = CustomerDialog(self.root, self.customer_service) + dialog.grab_set() + + def show_purchase_orders(self): + """Show purchase orders management interface""" + self.clear_content() + self.status_bar.configure(text="Purchase Orders") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Purchase Orders Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + purchase_frame = ctk.CTkFrame(self.content_frame) + purchase_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(purchase_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="New Purchase Order", + command=self.new_purchase_order, width=150, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Manage Suppliers", + command=self.manage_suppliers, width=150, height=32).pack(side="left", padx=5) + + # Content area for purchase orders + self.purchase_content_frame = ctk.CTkFrame(purchase_frame) + self.purchase_content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Load purchase orders + self.load_purchase_orders() + + def load_purchase_orders(self): + """Load and display purchase orders""" + # Clear existing content + for widget in self.purchase_content_frame.winfo_children(): + widget.destroy() + + # Get all purchase orders + orders = self.purchase_service.get_all_purchase_orders() + + if not orders: + ctk.CTkLabel(self.purchase_content_frame, + text="No purchase orders found. Click 'New Purchase Order' to create one.", + font=("Arial", 14)).pack(pady=50) + return + + # Create scrollable frame for orders + scroll_frame = ctk.CTkScrollableFrame(self.purchase_content_frame, height=400) + scroll_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Create headers + headers_frame = ctk.CTkFrame(scroll_frame) + headers_frame.pack(fill="x", padx=5, pady=5) + + headers = ["Order #", "Supplier", "Date", "Status", "Total", "Actions"] + for i, header in enumerate(headers): + ctk.CTkLabel(headers_frame, text=header, font=("Arial", 12, "bold")).grid( + row=0, column=i, padx=5, pady=5, sticky="w") + + # Display orders + for idx, order in enumerate(orders): + row_frame = ctk.CTkFrame(scroll_frame, height=24) + row_frame.pack(fill="x", padx=5, pady=2) + row_frame.pack_propagate(False) # Prevent frame from shrinking + + ctk.CTkLabel(row_frame, text=f"PO-{order.id}").grid( + row=0, column=0, padx=5, pady=5, sticky="w") + supplier = self.supplier_service.get_supplier(order.supplier_id) + supplier_name = supplier.name if supplier else "Unknown" + ctk.CTkLabel(row_frame, text=supplier_name).grid( + row=0, column=1, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=order.order_date.strftime("%Y-%m-%d")).grid( + row=0, column=2, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=order.status).grid( + row=0, column=3, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=f"Rp{order.total_amount:,.0f}".replace(",", ".")).grid( + row=0, column=4, padx=5, pady=5, sticky="w") + + # Actions + actions_frame = ctk.CTkFrame(row_frame, fg_color="transparent") + actions_frame.grid(row=0, column=5, padx=5, pady=5, sticky="w") + + # Add status change button for pending orders + if order.status == "pending": + ctk.CTkButton(actions_frame, text="Complete", + command=lambda o=order: self.complete_purchase_order(o), + width=70, height=24, fg_color="green").pack(side="left", padx=2) + + def show_manufacturing_orders(self): + """Show manufacturing orders management interface""" + self.clear_content() + self.status_bar.configure(text="Manufacturing Orders") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Manufacturing Orders Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + manufacturing_frame = ctk.CTkFrame(self.content_frame) + manufacturing_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(manufacturing_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="New Manufacturing Order", + command=self.new_manufacturing_order, width=180, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Production Planning", + command=self.production_planning, width=150, height=32).pack(side="left", padx=5) + + # Content area for manufacturing orders + self.manufacturing_content_frame = ctk.CTkFrame(manufacturing_frame) + self.manufacturing_content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Load manufacturing orders + self.load_manufacturing_orders() + + def load_manufacturing_orders(self): + """Load and display manufacturing orders""" + # Clear existing content + for widget in self.manufacturing_content_frame.winfo_children(): + widget.destroy() + + # Get all manufacturing orders + orders = self.manufacturing_service.get_all_manufacturing_orders() + + if not orders: + ctk.CTkLabel(self.manufacturing_content_frame, + text="No manufacturing orders found. Click 'New Manufacturing Order' to create one.", + font=("Arial", 14)).pack(pady=50) + return + + # Create scrollable frame for orders + scroll_frame = ctk.CTkScrollableFrame(self.manufacturing_content_frame, height=400) + scroll_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Create headers + headers_frame = ctk.CTkFrame(scroll_frame) + headers_frame.pack(fill="x", padx=5, pady=5) + + headers = ["Order #", "Product", "Quantity", "Start Date", "Status", "Actions"] + for i, header in enumerate(headers): + ctk.CTkLabel(headers_frame, text=header, font=("Arial", 12, "bold")).grid( + row=0, column=i, padx=5, pady=5, sticky="w") + + # Display orders + for idx, order in enumerate(orders): + row_frame = ctk.CTkFrame(scroll_frame, height=24) + row_frame.pack(fill="x", padx=5, pady=2) + row_frame.pack_propagate(False) # Prevent frame from shrinking + + ctk.CTkLabel(row_frame, text=f"MO-{order.id}").grid( + row=0, column=0, padx=5, pady=5, sticky="w") + product = self.product_service.get_product(order.product_id) + product_name = product.name if product else "Unknown" + ctk.CTkLabel(row_frame, text=product_name).grid( + row=0, column=1, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=str(order.quantity)).grid( + row=0, column=2, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=order.start_date.strftime("%Y-%m-%d")).grid( + row=0, column=3, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=order.status).grid( + row=0, column=4, padx=5, pady=5, sticky="w") + + # Actions + actions_frame = ctk.CTkFrame(row_frame, fg_color="transparent") + actions_frame.grid(row=0, column=5, padx=5, pady=5, sticky="w") + + # Add status change button for pending orders + if order.status == "pending": + ctk.CTkButton(actions_frame, text="Complete", + command=lambda o=order: self.complete_manufacturing_order(o), + width=70, height=24, fg_color="green").pack(side="left", padx=2) + + def show_sales_orders(self): + """Show sales orders management interface""" + self.clear_content() + self.status_bar.configure(text="Sales Orders") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Sales Orders Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + sales_frame = ctk.CTkFrame(self.content_frame) + sales_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(sales_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="New Sales Order", + command=self.new_sales_order, width=150, height=32).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Manage Customers", + command=self.manage_customers, width=150, height=32).pack(side="left", padx=5) + + # Content area for sales orders + self.sales_content_frame = ctk.CTkFrame(sales_frame) + self.sales_content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Load sales orders + self.load_sales_orders() + + def load_sales_orders(self): + """Load and display sales orders""" + # Clear existing content + for widget in self.sales_content_frame.winfo_children(): + widget.destroy() + + # Get all sales orders + orders = self.sales_service.get_all_sales_orders() + + if not orders: + ctk.CTkLabel(self.sales_content_frame, + text="No sales orders found. Click 'New Sales Order' to create one.", + font=("Arial", 14)).pack(pady=50) + return + + # Create scrollable frame for orders + scroll_frame = ctk.CTkScrollableFrame(self.sales_content_frame, height=400) + scroll_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Create headers + headers_frame = ctk.CTkFrame(scroll_frame) + headers_frame.pack(fill="x", padx=5, pady=5) + + headers = ["Order #", "Customer", "Date", "Status", "Total", "Actions"] + for i, header in enumerate(headers): + ctk.CTkLabel(headers_frame, text=header, font=("Arial", 12, "bold")).grid( + row=0, column=i, padx=5, pady=5, sticky="w") + + # Display orders + for idx, order in enumerate(orders): + row_frame = ctk.CTkFrame(scroll_frame, height=24) + row_frame.pack(fill="x", padx=5, pady=2) + row_frame.pack_propagate(False) # Prevent frame from shrinking + + ctk.CTkLabel(row_frame, text=f"SO-{order.id}").grid( + row=0, column=0, padx=5, pady=5, sticky="w") + customer = self.customer_service.get_customer(order.customer_id) + customer_name = customer.name if customer else "Unknown" + ctk.CTkLabel(row_frame, text=customer_name).grid( + row=0, column=1, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=order.order_date.strftime("%Y-%m-%d")).grid( + row=0, column=2, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=order.status).grid( + row=0, column=3, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=f"Rp{order.total_amount:,.0f}".replace(",", ".")).grid( + row=0, column=4, padx=5, pady=5, sticky="w") + + # Actions + actions_frame = ctk.CTkFrame(row_frame, fg_color="transparent") + actions_frame.grid(row=0, column=5, padx=5, pady=5, sticky="w") + + # Add status change button for pending orders + if order.status == "pending": + ctk.CTkButton(actions_frame, text="Complete", + command=lambda o=order: self.complete_sales_order(o), + width=70, height=24, fg_color="green").pack(side="left", padx=2) + + def view_purchase_order(self, order): + """View details of a specific purchase order""" + messagebox.showinfo("Purchase Order", f"Viewing Purchase Order #{order.id}") + + def view_manufacturing_order(self, order): + """View details of a specific manufacturing order""" + messagebox.showinfo("Manufacturing Order", f"Viewing Manufacturing Order #{order.id}") + + def view_sales_order(self, order): + """View details of a specific sales order""" + messagebox.showinfo("Sales Order", f"Viewing Sales Order #{order.id}") + + def view_inventory(self): + """View inventory""" + self.show_inventory_management() + + def stock_adjustment(self): + """Adjust stock levels""" + self.show_inventory_management() + + def show_inventory_management(self): + """Show detailed inventory management interface""" + self.clear_content() + self.status_bar.configure(text="Inventory Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Inventory Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + inventory_frame = ctk.CTkFrame(self.content_frame) + inventory_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(inventory_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Manage Products", + command=self.manage_products, width=150, height=32).pack(side="left", padx=5) + + # Content area for inventory + self.inventory_content_frame = ctk.CTkFrame(inventory_frame) + self.inventory_content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Load inventory data + self.load_inventory_data() + + def load_inventory_data(self): + """Load and display inventory data""" + # Clear existing content + for widget in self.inventory_content_frame.winfo_children(): + widget.destroy() + + # Get all products + products = self.product_service.get_all_products() + + if not products: + ctk.CTkLabel(self.inventory_content_frame, + text="No products found. Click 'Manage Products' to add products.", + font=("Arial", 14)).pack(pady=50) + return + + # Create scrollable frame for products + scroll_frame = ctk.CTkScrollableFrame(self.inventory_content_frame, height=400) + scroll_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Create headers + headers_frame = ctk.CTkFrame(scroll_frame) + headers_frame.pack(fill="x", padx=5, pady=5) + + headers = ["Product", "SKU", "Current Stock", "Min Stock", "Status", "Actions"] + for i, header in enumerate(headers): + ctk.CTkLabel(headers_frame, text=header, font=("Arial", 12, "bold")).grid( + row=0, column=i, padx=5, pady=5, sticky="w") + + # Display products + for idx, product in enumerate(products): + row_frame = ctk.CTkFrame(scroll_frame, height=24) + row_frame.pack(fill="x", padx=5, pady=2) + row_frame.pack_propagate(False) # Prevent frame from shrinking + + # Product info + ctk.CTkLabel(row_frame, text=product.name).grid( + row=0, column=0, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=product.sku or "-").grid( + row=0, column=1, padx=5, pady=5, sticky="w") + + # Get current stock + current_stock = self.inventory_service.get_current_stock(product.id) + ctk.CTkLabel(row_frame, text=str(current_stock)).grid( + row=0, column=2, padx=5, pady=5, sticky="w") + + ctk.CTkLabel(row_frame, text=str(product.min_stock)).grid( + row=0, column=3, padx=5, pady=5, sticky="w") + + # Status + status = "Low Stock" if current_stock <= product.min_stock else "OK" + status_color = "red" if current_stock <= product.min_stock else "green" + ctk.CTkLabel(row_frame, text=status, text_color=status_color).grid( + row=0, column=4, padx=5, pady=5, sticky="w") + + # Actions + actions_frame = ctk.CTkFrame(row_frame, fg_color="transparent") + actions_frame.grid(row=0, column=5, padx=5, pady=5, sticky="w") + + ctk.CTkButton(actions_frame, text="Edit", + command=lambda p=product: self.edit_product(p), + width=60, height=24).pack(side="left", padx=2) + ctk.CTkButton(actions_frame, text="Adjust", + command=lambda p=product: self.adjust_product_stock(p), + width=60, height=24).pack(side="left", padx=2) + + def manage_products(self): + """Open product management dialog""" + dialog = ProductDialog(self.root, self.product_service) + dialog.grab_set() + + def adjust_stock(self): + """Open stock adjustment interface""" + self.show_inventory_management() + + def edit_product(self, product): + """Edit a specific product""" + dialog = ProductDialog(self.root, self.product_service, product) + dialog.grab_set() + + def adjust_product_stock(self, product): + """Adjust stock for a specific product""" + # Create a simple stock adjustment dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title(f"Adjust Stock - {product.name}") + dialog.geometry("300x200") + dialog.transient(self.root) + dialog.grab_set() + + ctk.CTkLabel(dialog, text=f"Current Stock: {self.inventory_service.get_current_stock(product.id)}").pack(pady=10) + + ctk.CTkLabel(dialog, text="Adjustment Amount:").pack() + adjustment_entry = ctk.CTkEntry(dialog) + adjustment_entry.pack(pady=5) + + def adjust(): + try: + amount = int(adjustment_entry.get()) + self.inventory_service.adjust_inventory(product.id, amount, "Manual adjustment") + messagebox.showinfo("Success", f"Stock adjusted by {amount}") + dialog.destroy() + self.load_inventory_data() + except ValueError: + messagebox.showerror("Error", "Please enter a valid number") + + ctk.CTkButton(dialog, text="Adjust", command=adjust).pack(pady=10) + ctk.CTkButton(dialog, text="Cancel", command=dialog.destroy).pack(pady=5) + + + def manage_users(self): + """User management (admin only)""" + from src.ui.user_management_dialog import UserManagementDialog + dialog = UserManagementDialog(self.root, self.auth_manager) + self.root.wait_window(dialog) + + + def show_inventory_report(self): + """Show inventory report""" + try: + filename = self.inventory_service.export_inventory_report() + messagebox.showinfo("Success", f"Inventory report exported to {filename}") + except Exception as e: + messagebox.showerror("Error", f"Failed to export inventory report: {str(e)}") + + def show_sales_report(self): + """Show sales report with date range selection""" + from src.ui.date_range_dialog import DateRangeDialog + dialog = DateRangeDialog(self.root, "Select Date Range for Sales Report", "Export") + self.root.wait_window(dialog) + + start_date, end_date = dialog.get_date_range() + if start_date and end_date: + try: + filename = self.sales_service.export_sales_report(start_date, end_date) + messagebox.showinfo("Success", f"Sales report exported to {filename}") + except Exception as e: + messagebox.showerror("Error", f"Failed to export sales report: {str(e)}") + + def show_purchase_report(self): + """Show purchase report with date range selection""" + from src.ui.date_range_dialog import DateRangeDialog + dialog = DateRangeDialog(self.root, "Select Date Range for Purchase Report", "Export") + self.root.wait_window(dialog) + + start_date, end_date = dialog.get_date_range() + if start_date and end_date: + try: + filename = self.purchase_service.export_purchase_report(start_date, end_date) + messagebox.showinfo("Success", f"Purchase report exported to {filename}") + except Exception as e: + messagebox.showerror("Error", f"Failed to export purchase report: {str(e)}") + + def show_manufacturing_report(self): + """Show manufacturing report with date range selection""" + from src.ui.date_range_dialog import DateRangeDialog + dialog = DateRangeDialog(self.root, "Select Date Range for Manufacturing Report", "Export") + self.root.wait_window(dialog) + + start_date, end_date = dialog.get_date_range() + if start_date and end_date: + try: + filename = self.manufacturing_service.export_manufacturing_report(start_date, end_date) + messagebox.showinfo("Success", f"Manufacturing report exported to {filename}") + except Exception as e: + messagebox.showerror("Error", f"Failed to export manufacturing report: {str(e)}") + + def show_stock_movement_report(self): + """Show stock movement report""" + try: + filename = self.inventory_service.export_stock_movement_report() + messagebox.showinfo("Success", f"Stock movement report exported to {filename}") + except Exception as e: + messagebox.showerror("Error", f"Failed to export stock movement report: {str(e)}") + + def complete_purchase_order(self, order): + """Complete a purchase order""" + if messagebox.askyesno("Confirm", f"Are you sure you want to complete purchase order #{order.id}?"): + if self.purchase_service.receive_purchase_order(order.id): + messagebox.showinfo("Success", f"Purchase order #{order.id} completed successfully") + self.load_purchase_orders() + else: + messagebox.showerror("Error", f"Failed to complete purchase order #{order.id}") + + def complete_manufacturing_order(self, order): + """Complete a manufacturing order""" + if messagebox.askyesno("Confirm", f"Are you sure you want to complete manufacturing order #{order.id}?"): + if self.manufacturing_service.complete_manufacturing_order(order.id): + messagebox.showinfo("Success", f"Manufacturing order #{order.id} completed successfully") + self.load_manufacturing_orders() + else: + messagebox.showerror("Error", f"Failed to complete manufacturing order #{order.id}") + + def complete_sales_order(self, order): + """Complete a sales order""" + if messagebox.askyesno("Confirm", f"Are you sure you want to complete sales order #{order.id}?"): + if self.sales_service.deliver_sales_order(order.id): + messagebox.showinfo("Success", f"Sales order #{order.id} completed successfully") + self.load_sales_orders() + else: + messagebox.showerror("Error", f"Failed to complete sales order #{order.id}") + + def view_purchase_order(self, order): + """View details of a specific purchase order""" + messagebox.showinfo("Purchase Order", f"Viewing Purchase Order #{order.id}") + + def view_manufacturing_order(self, order): + """View details of a specific manufacturing order""" + messagebox.showinfo("Manufacturing Order", f"Viewing Manufacturing Order #{order.id}") + + def view_sales_order(self, order): + """View details of a specific sales order""" + messagebox.showinfo("Sales Order", f"Viewing Sales Order #{order.id}") + + def show_supplier_list(self): + """Show supplier management interface""" + self.clear_content() + self.status_bar.configure(text="Supplier Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Supplier Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + supplier_frame = ctk.CTkFrame(self.content_frame) + supplier_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(supplier_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Add New Supplier", + command=self.add_supplier, width=150, height=32).pack(side="left", padx=5) + + # Content area for suppliers + self.supplier_content_frame = ctk.CTkFrame(supplier_frame) + self.supplier_content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Load suppliers + self.load_suppliers() + + def load_suppliers(self): + """Load and display suppliers""" + # Clear existing content + for widget in self.supplier_content_frame.winfo_children(): + widget.destroy() + + # Get all suppliers + suppliers = self.supplier_service.get_all_suppliers() + + if not suppliers: + ctk.CTkLabel(self.supplier_content_frame, + text="No suppliers found. Click 'Add New Supplier' to create one.", + font=("Arial", 14)).pack(pady=50) + return + + # Create scrollable frame for suppliers + scroll_frame = ctk.CTkScrollableFrame(self.supplier_content_frame, height=400) + scroll_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Create headers + headers_frame = ctk.CTkFrame(scroll_frame) + headers_frame.pack(fill="x", padx=5, pady=5) + + headers = ["Name", "Contact Person", "Phone", "Email", "Actions"] + for i, header in enumerate(headers): + ctk.CTkLabel(headers_frame, text=header, font=("Arial", 12, "bold")).grid( + row=0, column=i, padx=5, pady=5, sticky="w") + + # Display suppliers + for idx, supplier in enumerate(suppliers): + row_frame = ctk.CTkFrame(scroll_frame, height=24) + row_frame.pack(fill="x", padx=5, pady=2) + row_frame.pack_propagate(False) # Prevent frame from shrinking + + ctk.CTkLabel(row_frame, text=supplier.name).grid( + row=0, column=0, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=supplier.contact_person or "-").grid( + row=0, column=1, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=supplier.phone or "-").grid( + row=0, column=2, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=supplier.email or "-").grid( + row=0, column=3, padx=5, pady=5, sticky="w") + + # Actions + actions_frame = ctk.CTkFrame(row_frame, fg_color="transparent") + actions_frame.grid(row=0, column=4, padx=5, pady=5, sticky="w") + + ctk.CTkButton(actions_frame, text="Edit", + command=lambda s=supplier: self.edit_supplier(s), + width=60, height=24).pack(side="left", padx=2) + ctk.CTkButton(actions_frame, text="Delete", + command=lambda s=supplier: self.delete_supplier(s), + width=60, height=24, fg_color="red", hover_color="dark red").pack(side="left", padx=2) + + def add_supplier(self): + """Add a new supplier""" + from src.ui.supplier_dialog import SupplierDialog + dialog = SupplierDialog(self.root, self.supplier_service) + self.root.wait_window(dialog) + self.load_suppliers() + + def edit_supplier(self, supplier): + """Edit a supplier""" + from src.ui.supplier_dialog import SupplierDialog + dialog = SupplierDialog(self.root, self.supplier_service, supplier) + self.root.wait_window(dialog) + self.load_suppliers() + + def delete_supplier(self, supplier): + """Delete a supplier""" + if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete supplier '{supplier.name}'?"): + if self.supplier_service.delete_supplier(supplier.id): + messagebox.showinfo("Success", f"Supplier '{supplier.name}' deleted successfully") + self.load_suppliers() + else: + messagebox.showerror("Error", f"Failed to delete supplier '{supplier.name}'") + + def show_customer_list(self): + """Show customer management interface""" + self.clear_content() + self.status_bar.configure(text="Customer Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Customer Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + customer_frame = ctk.CTkFrame(self.content_frame) + customer_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(customer_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Add New Customer", + command=self.add_customer, width=150, height=32).pack(side="left", padx=5) + + # Content area for customers + self.customer_content_frame = ctk.CTkFrame(customer_frame) + self.customer_content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Load customers + self.load_customers() + + def load_customers(self): + """Load and display customers""" + # Clear existing content + for widget in self.customer_content_frame.winfo_children(): + widget.destroy() + + # Get all customers + customers = self.customer_service.get_all_customers() + + if not customers: + ctk.CTkLabel(self.customer_content_frame, + text="No customers found. Click 'Add New Customer' to create one.", + font=("Arial", 14)).pack(pady=50) + return + + # Create scrollable frame for customers + scroll_frame = ctk.CTkScrollableFrame(self.customer_content_frame, height=400) + scroll_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Create headers + headers_frame = ctk.CTkFrame(scroll_frame) + headers_frame.pack(fill="x", padx=5, pady=5) + + headers = ["Name", "Contact Person", "Phone", "Email", "Actions"] + for i, header in enumerate(headers): + ctk.CTkLabel(headers_frame, text=header, font=("Arial", 12, "bold")).grid( + row=0, column=i, padx=5, pady=5, sticky="w") + + # Display customers + for idx, customer in enumerate(customers): + row_frame = ctk.CTkFrame(scroll_frame, height=24) + row_frame.pack(fill="x", padx=5, pady=2) + row_frame.pack_propagate(False) # Prevent frame from shrinking + + ctk.CTkLabel(row_frame, text=customer.name).grid( + row=0, column=0, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=customer.contact_person or "-").grid( + row=0, column=1, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=customer.phone or "-").grid( + row=0, column=2, padx=5, pady=5, sticky="w") + ctk.CTkLabel(row_frame, text=customer.email or "-").grid( + row=0, column=3, padx=5, pady=5, sticky="w") + + # Actions + actions_frame = ctk.CTkFrame(row_frame, fg_color="transparent") + actions_frame.grid(row=0, column=4, padx=5, pady=5, sticky="w") + + ctk.CTkButton(actions_frame, text="Edit", + command=lambda c=customer: self.edit_customer(c), + width=60, height=24).pack(side="left", padx=2) + ctk.CTkButton(actions_frame, text="Delete", + command=lambda c=customer: self.delete_customer(c), + width=60, height=24, fg_color="red", hover_color="dark red").pack(side="left", padx=2) + + def add_customer(self): + """Add a new customer""" + from src.ui.customer_dialog import CustomerDialog + dialog = CustomerDialog(self.root, self.customer_service) + self.root.wait_window(dialog) + self.load_customers() + + def edit_customer(self, customer): + """Edit a customer""" + from src.ui.customer_dialog import CustomerDialog + dialog = CustomerDialog(self.root, self.customer_service, customer) + self.root.wait_window(dialog) + self.load_customers() + + def delete_customer(self, customer): + """Delete a customer""" + if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete customer '{customer.name}'?"): + if self.customer_service.delete_customer(customer.id): + messagebox.showinfo("Success", f"Customer '{customer.name}' deleted successfully") + self.load_customers() + else: + messagebox.showerror("Error", f"Failed to delete customer '{customer.name}'") + + def show_product_list(self): + """Show product management interface""" + self.clear_content() + self.status_bar.configure(text="Product Management") + + # Title + title = ctk.CTkLabel(self.content_frame, text="Product Management", + font=("Arial", 24, "bold")) + title.pack(pady=(20, 30)) + + # Main frame + product_frame = ctk.CTkFrame(self.content_frame) + product_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Buttons frame + button_frame = ctk.CTkFrame(product_frame) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Add New Product", + command=self.add_product, width=150, height=32).pack(side="left", padx=5) + + # Show inventory management which displays product list + self.show_inventory_management() + + def add_product(self): + """Add a new product""" + from src.ui.product_dialog import ProductDialog + dialog = ProductDialog(self.root, self.product_service) + self.root.wait_window(dialog) + self.load_inventory_data() \ No newline at end of file diff --git a/src/ui/manufacturing_order_dialog.py b/src/ui/manufacturing_order_dialog.py new file mode 100644 index 0000000..eca604c --- /dev/null +++ b/src/ui/manufacturing_order_dialog.py @@ -0,0 +1,107 @@ +import customtkinter as ctk +from tkinter import messagebox +from datetime import datetime +from typing import Optional, List +from src.models import ManufacturingOrder, Product +from src.services import ManufacturingOrderService, ProductService + +class ManufacturingOrderDialog(ctk.CTkToplevel): + def __init__(self, parent, manufacturing_service: ManufacturingOrderService, + product_service: ProductService, + manufacturing_order: Optional[ManufacturingOrder] = None): + super().__init__(parent) + self.manufacturing_service = manufacturing_service + self.product_service = product_service + self.manufacturing_order = manufacturing_order + + self.title("New Manufacturing Order" if not manufacturing_order else "Edit Manufacturing Order") + self.geometry("500x400") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + + def setup_ui(self): + # Product selection + ctk.CTkLabel(self, text="Product:").grid(row=0, column=0, padx=10, pady=5, sticky="w") + self.product_var = ctk.StringVar() + self.product_combo = ctk.CTkComboBox(self, variable=self.product_var, width=200) + self.product_combo.grid(row=0, column=1, padx=10, pady=5) + + # Quantity + ctk.CTkLabel(self, text="Quantity:").grid(row=1, column=0, padx=10, pady=5, sticky="w") + self.quantity_entry = ctk.CTkEntry(self, width=200) + self.quantity_entry.grid(row=1, column=1, padx=10, pady=5) + + # Start date + ctk.CTkLabel(self, text="Start Date:").grid(row=2, column=0, padx=10, pady=5, sticky="w") + self.start_date_entry = ctk.CTkEntry(self, width=200) + self.start_date_entry.insert(0, datetime.now().strftime("%Y-%m-%d")) + self.start_date_entry.grid(row=2, column=1, padx=10, pady=5) + + # Expected completion + ctk.CTkLabel(self, text="Expected Completion:").grid(row=3, column=0, padx=10, pady=5, sticky="w") + self.expected_completion_entry = ctk.CTkEntry(self, width=200) + self.expected_completion_entry.grid(row=3, column=1, padx=10, pady=5) + + # Status + ctk.CTkLabel(self, text="Status:").grid(row=4, column=0, padx=10, pady=5, sticky="w") + self.status_var = ctk.StringVar(value="planned") + self.status_combo = ctk.CTkComboBox(self, variable=self.status_var, + values=["planned", "in_progress", "completed", "cancelled"]) + self.status_combo.grid(row=4, column=1, padx=10, pady=5) + + # Notes + ctk.CTkLabel(self, text="Notes:").grid(row=5, column=0, padx=10, pady=5, sticky="nw") + self.notes_text = ctk.CTkTextbox(self, height=100, width=200) + self.notes_text.grid(row=5, column=1, padx=10, pady=5) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.grid(row=6, column=0, columnspan=2, pady=20) + + ctk.CTkButton(button_frame, text="Save", command=self.save_order).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + # Load data + self.load_products() + + def load_products(self): + products = self.product_service.get_all_products() + product_names = [p.name for p in products] + self.product_combo.configure(values=product_names) + + def save_order(self): + product_name = self.product_var.get() + try: + quantity = int(self.quantity_entry.get()) + except ValueError: + messagebox.showerror("Error", "Please enter a valid quantity") + return + + if quantity <= 0: + messagebox.showerror("Error", "Quantity must be positive") + return + + product = next((p for p in self.product_service.get_all_products() if p.name == product_name), None) + if not product: + messagebox.showerror("Error", "Please select a valid product") + return + + # Create manufacturing order + mo = self.manufacturing_service.create_manufacturing_order( + product_id=product.id, + quantity=quantity, + start_date=self.start_date_entry.get(), + expected_completion=self.expected_completion_entry.get() or None, + status=self.status_var.get(), + notes=self.notes_text.get("1.0", "end-1c") + ) + + if mo: + messagebox.showinfo("Success", f"Manufacturing order #{mo.id} created successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to create manufacturing order") \ No newline at end of file diff --git a/src/ui/product_dialog.py b/src/ui/product_dialog.py new file mode 100644 index 0000000..e099d69 --- /dev/null +++ b/src/ui/product_dialog.py @@ -0,0 +1,83 @@ +import customtkinter as ctk +from tkinter import messagebox +from typing import Optional +from src.models import Product +from src.services import ProductService + +class ProductDialog(ctk.CTkToplevel): + def __init__(self, parent, product_service: ProductService, product: Optional[Product] = None): + super().__init__(parent) + self.product_service = product_service + self.product = product + + self.title("Add Product" if not product else "Edit Product") + self.geometry("400x300") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + + def setup_ui(self): + # Name + ctk.CTkLabel(self, text="Name:").grid(row=0, column=0, padx=10, pady=5, sticky="w") + self.name_entry = ctk.CTkEntry(self, width=250) + self.name_entry.grid(row=0, column=1, padx=10, pady=5) + + # Description + ctk.CTkLabel(self, text="Description:").grid(row=1, column=0, padx=10, pady=5, sticky="nw") + self.description_text = ctk.CTkTextbox(self, width=250, height=80) + self.description_text.grid(row=1, column=1, padx=10, pady=5) + + # Unit Price + ctk.CTkLabel(self, text="Unit Price:").grid(row=2, column=0, padx=10, pady=5, sticky="w") + self.price_entry = ctk.CTkEntry(self, width=250) + self.price_entry.grid(row=2, column=1, padx=10, pady=5) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.grid(row=3, column=0, columnspan=2, pady=20) + + ctk.CTkButton(button_frame, text="Save", command=self.save_product).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + # Load data if editing + if self.product: + self.name_entry.insert(0, self.product.name) + self.description_text.insert("1.0", self.product.description) + self.price_entry.insert(0, str(self.product.unit_price)) + + def save_product(self): + name = self.name_entry.get().strip() + description = self.description_text.get("1.0", "end-1c").strip() + + try: + unit_price = float(self.price_entry.get()) + except ValueError: + messagebox.showerror("Error", "Please enter a valid price") + return + + if not name: + messagebox.showerror("Error", "Please enter a product name") + return + + if self.product: + # Update existing product + self.product.name = name + self.product.description = description + self.product.unit_price = unit_price + + if self.product_service.update_product(self.product): + messagebox.showinfo("Success", "Product updated successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to update product") + else: + # Create new product + product = self.product_service.create_product(name, description, unit_price) + if product: + messagebox.showinfo("Success", "Product created successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to create product") \ No newline at end of file diff --git a/src/ui/purchase_order_dialog.py b/src/ui/purchase_order_dialog.py new file mode 100644 index 0000000..5f0e5b9 --- /dev/null +++ b/src/ui/purchase_order_dialog.py @@ -0,0 +1,233 @@ +import customtkinter as ctk +from tkinter import messagebox +from datetime import datetime, date +from typing import Optional, List +from src.models import PurchaseOrder, PurchaseOrderItem, Product, Supplier +from src.services import PurchaseOrderService, ProductService, SupplierService +from src.ui.date_picker_dialog import DatePickerDialog + +class PurchaseOrderDialog(ctk.CTkToplevel): + def __init__(self, parent, purchase_service: PurchaseOrderService, + product_service: ProductService, supplier_service: SupplierService, + purchase_order: Optional[PurchaseOrder] = None): + super().__init__(parent) + self.purchase_service = purchase_service + self.product_service = product_service + self.supplier_service = supplier_service + self.purchase_order = purchase_order + + self.title("New Purchase Order" if not purchase_order else "Edit Purchase Order") + self.geometry("600x500") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.items = [] + self.setup_ui() + + def setup_ui(self): + # Supplier selection + ctk.CTkLabel(self, text="Supplier:").grid(row=0, column=0, padx=10, pady=5, sticky="w") + self.supplier_var = ctk.StringVar() + self.supplier_combo = ctk.CTkComboBox(self, variable=self.supplier_var, width=200) + self.supplier_combo.grid(row=0, column=1, padx=10, pady=5) + + # Order date + ctk.CTkLabel(self, text="Order Date:").grid(row=1, column=0, padx=10, pady=5, sticky="w") + date_frame1 = ctk.CTkFrame(self) + date_frame1.grid(row=1, column=1, padx=10, pady=5, sticky="w") + self.order_date_entry = ctk.CTkEntry(date_frame1, width=150) + self.order_date_entry.insert(0, datetime.now().strftime("%Y-%m-%d")) + self.order_date_entry.pack(side="left") + ctk.CTkButton(date_frame1, text="...", width=30, command=self.select_order_date).pack(side="left", padx=(5, 0)) + + # Expected delivery + ctk.CTkLabel(self, text="Expected Delivery:").grid(row=2, column=0, padx=10, pady=5, sticky="w") + date_frame2 = ctk.CTkFrame(self) + date_frame2.grid(row=2, column=1, padx=10, pady=5, sticky="w") + self.expected_delivery_entry = ctk.CTkEntry(date_frame2, width=150) + self.expected_delivery_entry.pack(side="left") + ctk.CTkButton(date_frame2, text="...", width=30, command=self.select_expected_delivery).pack(side="left", padx=(5, 0)) + + # Items frame + items_frame = ctk.CTkFrame(self) + items_frame.grid(row=3, column=0, columnspan=2, padx=10, pady=10, sticky="nsew") + + # Add item controls + add_frame = ctk.CTkFrame(items_frame) + add_frame.pack(fill="x", padx=5, pady=5) + + ctk.CTkLabel(add_frame, text="Product:").pack(side="left", padx=5) + self.product_var = ctk.StringVar() + self.product_combo = ctk.CTkComboBox(add_frame, variable=self.product_var, width=150) + self.product_combo.pack(side="left", padx=5) + + ctk.CTkLabel(add_frame, text="Qty:").pack(side="left", padx=5) + self.quantity_entry = ctk.CTkEntry(add_frame, width=50) + self.quantity_entry.pack(side="left", padx=5) + + ctk.CTkLabel(add_frame, text="Price:").pack(side="left", padx=5) + self.price_entry = ctk.CTkEntry(add_frame, width=80) + self.price_entry.pack(side="left", padx=5) + + ctk.CTkButton(add_frame, text="Add", command=self.add_item, width=60).pack(side="left", padx=5) + + # Items list + self.items_text = ctk.CTkTextbox(items_frame, height=150) + self.items_text.pack(fill="both", expand=True, padx=5, pady=5) + + # Total + self.total_label = ctk.CTkLabel(self, text="Total: Rp0", font=("Arial", 14, "bold")) + self.total_label.grid(row=4, column=0, columnspan=2, pady=10) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.grid(row=5, column=0, columnspan=2, pady=10) + + ctk.CTkButton(button_frame, text="Save", command=self.save_order).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + # Load data + self.load_suppliers() + self.load_products() + + def load_suppliers(self): + suppliers = self.supplier_service.get_all_suppliers() + supplier_names = [s.name for s in suppliers] + self.supplier_combo.configure(values=supplier_names) + + def load_products(self): + products = self.product_service.get_all_products() + product_names = [p.name for p in products] + self.product_combo.configure(values=product_names) + + def add_item(self): + product_name = self.product_var.get() + try: + quantity = int(self.quantity_entry.get()) + price = float(self.price_entry.get()) + except ValueError: + messagebox.showerror("Error", "Please enter valid quantity and price") + return + + if quantity <= 0 or price <= 0: + messagebox.showerror("Error", "Quantity and price must be positive") + return + + product = next((p for p in self.product_service.get_all_products() if p.name == product_name), None) + if not product: + messagebox.showerror("Error", "Please select a valid product") + return + + total = quantity * price + self.items.append({ + 'product': product, + 'quantity': quantity, + 'price': price, + 'total': total + }) + + self.update_items_display() + self.update_total() + + # Clear inputs + self.quantity_entry.delete(0, 'end') + self.price_entry.delete(0, 'end') + + def update_items_display(self): + self.items_text.delete("1.0", "end") + for item in self.items: + self.items_text.insert("end", + f"{item['product'].name} - {item['quantity']} x Rp{item['price']:,.0f} = Rp{item['total']:,.0f}\n") + + def update_total(self): + total = sum(item['total'] for item in self.items) + self.total_label.configure(text=f"Total: Rp{total:,.0f}") + + def save_order(self): + supplier_name = self.supplier_var.get() + if not supplier_name: + messagebox.showerror("Error", "Please select a supplier") + return + + if not self.items: + messagebox.showerror("Error", "Please add at least one item") + return + + supplier = next((s for s in self.supplier_service.get_all_suppliers() if s.name == supplier_name), None) + if not supplier: + messagebox.showerror("Error", "Please select a valid supplier") + return + + total = sum(item['total'] for item in self.items) + + # Create purchase order + # Convert string dates to date objects + try: + order_date = datetime.strptime(self.order_date_entry.get(), "%Y-%m-%d").date() + except ValueError: + order_date = datetime.now().date() + + expected_delivery = None + if self.expected_delivery_entry.get(): + try: + expected_delivery = datetime.strptime(self.expected_delivery_entry.get(), "%Y-%m-%d").date() + except ValueError: + pass + + po = self.purchase_service.create_purchase_order( + supplier_id=supplier.id, + order_date=order_date, + expected_delivery=expected_delivery, + total_amount=total + ) + + if po: + # Add items + for item in self.items: + self.purchase_service.add_purchase_order_item( + po_id=po.id, + product_id=item['product'].id, + quantity=item['quantity'], + unit_price=item['price'], + total_price=item['total'] + ) + + messagebox.showinfo("Success", f"Purchase order #{po.id} created successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to create purchase order") + + def select_order_date(self): + """Open date picker for order date""" + try: + current_date = datetime.strptime(self.order_date_entry.get(), "%Y-%m-%d").date() + except ValueError: + current_date = date.today() + + dialog = DatePickerDialog(self, "Select Order Date", current_date) + self.wait_window(dialog) + + selected_date = dialog.get_selected_date() + if selected_date: + self.order_date_entry.delete(0, 'end') + self.order_date_entry.insert(0, selected_date.strftime("%Y-%m-%d")) + + def select_expected_delivery(self): + """Open date picker for expected delivery date""" + if self.expected_delivery_entry.get(): + try: + current_date = datetime.strptime(self.expected_delivery_entry.get(), "%Y-%m-%d").date() + except ValueError: + current_date = date.today() + else: + current_date = date.today() + + dialog = DatePickerDialog(self, "Select Expected Delivery Date", current_date) + self.wait_window(dialog) + + selected_date = dialog.get_selected_date() + if selected_date: + self.expected_delivery_entry.delete(0, 'end') + self.expected_delivery_entry.insert(0, selected_date.strftime("%Y-%m-%d")) \ No newline at end of file diff --git a/src/ui/sales_order_dialog.py b/src/ui/sales_order_dialog.py new file mode 100644 index 0000000..b71c63b --- /dev/null +++ b/src/ui/sales_order_dialog.py @@ -0,0 +1,233 @@ +import customtkinter as ctk +from tkinter import messagebox +from datetime import datetime, date +from typing import Optional, List +from src.models import SalesOrder, SalesOrderItem, Product, Customer +from src.services import SalesOrderService, ProductService, CustomerService +from src.ui.date_picker_dialog import DatePickerDialog + +class SalesOrderDialog(ctk.CTkToplevel): + def __init__(self, parent, sales_service: SalesOrderService, + product_service: ProductService, customer_service: CustomerService, + sales_order: Optional[SalesOrder] = None): + super().__init__(parent) + self.sales_service = sales_service + self.product_service = product_service + self.customer_service = customer_service + self.sales_order = sales_order + + self.title("New Sales Order" if not sales_order else "Edit Sales Order") + self.geometry("600x500") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.items = [] + self.setup_ui() + + def setup_ui(self): + # Customer selection + ctk.CTkLabel(self, text="Customer:").grid(row=0, column=0, padx=10, pady=5, sticky="w") + self.customer_var = ctk.StringVar() + self.customer_combo = ctk.CTkComboBox(self, variable=self.customer_var, width=200) + self.customer_combo.grid(row=0, column=1, padx=10, pady=5) + + # Order date + ctk.CTkLabel(self, text="Order Date:").grid(row=1, column=0, padx=10, pady=5, sticky="w") + date_frame1 = ctk.CTkFrame(self) + date_frame1.grid(row=1, column=1, padx=10, pady=5, sticky="w") + self.order_date_entry = ctk.CTkEntry(date_frame1, width=150) + self.order_date_entry.insert(0, datetime.now().strftime("%Y-%m-%d")) + self.order_date_entry.pack(side="left") + ctk.CTkButton(date_frame1, text="...", width=30, command=self.select_order_date).pack(side="left", padx=(5, 0)) + + # Expected delivery + ctk.CTkLabel(self, text="Expected Delivery:").grid(row=2, column=0, padx=10, pady=5, sticky="w") + date_frame2 = ctk.CTkFrame(self) + date_frame2.grid(row=2, column=1, padx=10, pady=5, sticky="w") + self.expected_delivery_entry = ctk.CTkEntry(date_frame2, width=150) + self.expected_delivery_entry.pack(side="left") + ctk.CTkButton(date_frame2, text="...", width=30, command=self.select_expected_delivery).pack(side="left", padx=(5, 0)) + + # Items frame + items_frame = ctk.CTkFrame(self) + items_frame.grid(row=3, column=0, columnspan=2, padx=10, pady=10, sticky="nsew") + + # Add item controls + add_frame = ctk.CTkFrame(items_frame) + add_frame.pack(fill="x", padx=5, pady=5) + + ctk.CTkLabel(add_frame, text="Product:").pack(side="left", padx=5) + self.product_var = ctk.StringVar() + self.product_combo = ctk.CTkComboBox(add_frame, variable=self.product_var, width=150) + self.product_combo.pack(side="left", padx=5) + + ctk.CTkLabel(add_frame, text="Qty:").pack(side="left", padx=5) + self.quantity_entry = ctk.CTkEntry(add_frame, width=50) + self.quantity_entry.pack(side="left", padx=5) + + ctk.CTkLabel(add_frame, text="Price:").pack(side="left", padx=5) + self.price_entry = ctk.CTkEntry(add_frame, width=80) + self.price_entry.pack(side="left", padx=5) + + ctk.CTkButton(add_frame, text="Add", command=self.add_item, width=60).pack(side="left", padx=5) + + # Items list + self.items_text = ctk.CTkTextbox(items_frame, height=150) + self.items_text.pack(fill="both", expand=True, padx=5, pady=5) + + # Total + self.total_label = ctk.CTkLabel(self, text="Total: Rp0", font=("Arial", 14, "bold")) + self.total_label.grid(row=4, column=0, columnspan=2, pady=10) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.grid(row=5, column=0, columnspan=2, pady=10) + + ctk.CTkButton(button_frame, text="Save", command=self.save_order).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + # Load data + self.load_customers() + self.load_products() + + def load_customers(self): + customers = self.customer_service.get_all_customers() + customer_names = [c.name for c in customers] + self.customer_combo.configure(values=customer_names) + + def load_products(self): + products = self.product_service.get_all_products() + product_names = [p.name for p in products] + self.product_combo.configure(values=product_names) + + def add_item(self): + product_name = self.product_var.get() + try: + quantity = int(self.quantity_entry.get()) + price = float(self.price_entry.get()) + except ValueError: + messagebox.showerror("Error", "Please enter valid quantity and price") + return + + if quantity <= 0 or price <= 0: + messagebox.showerror("Error", "Quantity and price must be positive") + return + + product = next((p for p in self.product_service.get_all_products() if p.name == product_name), None) + if not product: + messagebox.showerror("Error", "Please select a valid product") + return + + total = quantity * price + self.items.append({ + 'product': product, + 'quantity': quantity, + 'price': price, + 'total': total + }) + + self.update_items_display() + self.update_total() + + # Clear inputs + self.quantity_entry.delete(0, 'end') + self.price_entry.delete(0, 'end') + + def update_items_display(self): + self.items_text.delete("1.0", "end") + for item in self.items: + self.items_text.insert("end", + f"{item['product'].name} - {item['quantity']} x Rp{item['price']:,.0f} = Rp{item['total']:,.0f}\n") + + def update_total(self): + total = sum(item['total'] for item in self.items) + self.total_label.configure(text=f"Total: Rp{total:,.0f}") + + def save_order(self): + customer_name = self.customer_var.get() + if not customer_name: + messagebox.showerror("Error", "Please select a customer") + return + + if not self.items: + messagebox.showerror("Error", "Please add at least one item") + return + + customer = next((c for c in self.customer_service.get_all_customers() if c.name == customer_name), None) + if not customer: + messagebox.showerror("Error", "Please select a valid customer") + return + + total = sum(item['total'] for item in self.items) + + # Create sales order + # Convert string dates to date objects + try: + order_date = datetime.strptime(self.order_date_entry.get(), "%Y-%m-%d").date() + except ValueError: + order_date = datetime.now().date() + + expected_delivery = None + if self.expected_delivery_entry.get(): + try: + expected_delivery = datetime.strptime(self.expected_delivery_entry.get(), "%Y-%m-%d").date() + except ValueError: + pass + + so = self.sales_service.create_sales_order( + customer_id=customer.id, + order_date=order_date, + expected_delivery=expected_delivery, + total_amount=total + ) + + if so: + # Add items + for item in self.items: + self.sales_service.add_sales_order_item( + so_id=so.id, + product_id=item['product'].id, + quantity=item['quantity'], + unit_price=item['price'], + total_price=item['total'] + ) + + messagebox.showinfo("Success", f"Sales order #{so.id} created successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to create sales order") + + def select_order_date(self): + """Open date picker for order date""" + try: + current_date = datetime.strptime(self.order_date_entry.get(), "%Y-%m-%d").date() + except ValueError: + current_date = date.today() + + dialog = DatePickerDialog(self, "Select Order Date", current_date) + self.wait_window(dialog) + + selected_date = dialog.get_selected_date() + if selected_date: + self.order_date_entry.delete(0, 'end') + self.order_date_entry.insert(0, selected_date.strftime("%Y-%m-%d")) + + def select_expected_delivery(self): + """Open date picker for expected delivery date""" + if self.expected_delivery_entry.get(): + try: + current_date = datetime.strptime(self.expected_delivery_entry.get(), "%Y-%m-%d").date() + except ValueError: + current_date = date.today() + else: + current_date = date.today() + + dialog = DatePickerDialog(self, "Select Expected Delivery Date", current_date) + self.wait_window(dialog) + + selected_date = dialog.get_selected_date() + if selected_date: + self.expected_delivery_entry.delete(0, 'end') + self.expected_delivery_entry.insert(0, selected_date.strftime("%Y-%m-%d")) \ No newline at end of file diff --git a/src/ui/supplier_dialog.py b/src/ui/supplier_dialog.py new file mode 100644 index 0000000..911e72c --- /dev/null +++ b/src/ui/supplier_dialog.py @@ -0,0 +1,108 @@ +import customtkinter as ctk +from tkinter import messagebox +from typing import Optional +from src.models import Supplier +from src.services import SupplierService + +class SupplierDialog(ctk.CTkToplevel): + def __init__(self, parent, supplier_service: SupplierService, + supplier: Optional[Supplier] = None): + super().__init__(parent) + self.supplier_service = supplier_service + self.supplier = supplier + + self.title("New Supplier" if not supplier else "Edit Supplier") + self.geometry("400x350") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + + def setup_ui(self): + # Name + ctk.CTkLabel(self, text="Name:").grid(row=0, column=0, padx=10, pady=5, sticky="w") + self.name_entry = ctk.CTkEntry(self, width=250) + self.name_entry.grid(row=0, column=1, padx=10, pady=5) + + # Email + ctk.CTkLabel(self, text="Email:").grid(row=1, column=0, padx=10, pady=5, sticky="w") + self.email_entry = ctk.CTkEntry(self, width=250) + self.email_entry.grid(row=1, column=1, padx=10, pady=5) + + # Phone + ctk.CTkLabel(self, text="Phone:").grid(row=2, column=0, padx=10, pady=5, sticky="w") + self.phone_entry = ctk.CTkEntry(self, width=250) + self.phone_entry.grid(row=2, column=1, padx=10, pady=5) + + # Contact Person + ctk.CTkLabel(self, text="Contact Person:").grid(row=3, column=0, padx=10, pady=5, sticky="w") + self.contact_person_entry = ctk.CTkEntry(self, width=250) + self.contact_person_entry.grid(row=3, column=1, padx=10, pady=5) + + # Address + ctk.CTkLabel(self, text="Address:").grid(row=4, column=0, padx=10, pady=5, sticky="nw") + self.address_text = ctk.CTkTextbox(self, height=80, width=250) + self.address_text.grid(row=4, column=1, padx=10, pady=5) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.grid(row=5, column=0, columnspan=2, pady=20) + + ctk.CTkButton(button_frame, text="Save", command=self.save_supplier).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + # Load data if editing + if self.supplier: + self.load_supplier_data() + + def load_supplier_data(self): + self.name_entry.insert(0, self.supplier.name) + self.email_entry.insert(0, self.supplier.email or "") + self.phone_entry.insert(0, self.supplier.phone or "") + self.contact_person_entry.insert(0, self.supplier.contact_person or "") + self.address_text.insert("1.0", self.supplier.address or "") + + def save_supplier(self): + name = self.name_entry.get().strip() + if not name: + messagebox.showerror("Error", "Please enter supplier name") + return + + email = self.email_entry.get().strip() + phone = self.phone_entry.get().strip() + contact_person = self.contact_person_entry.get().strip() + address = self.address_text.get("1.0", "end-1c").strip() + + if self.supplier: + # Update existing supplier + updated = self.supplier_service.update_supplier( + supplier_id=self.supplier.id, + name=name, + contact_person=contact_person or None, + email=email or None, + phone=phone or None, + address=address or None + ) + + if updated: + messagebox.showinfo("Success", "Supplier updated successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to update supplier") + else: + # Create new supplier + supplier = self.supplier_service.create_supplier( + name=name, + contact_person=contact_person or None, + email=email or None, + phone=phone or None, + address=address or None + ) + + if supplier: + messagebox.showinfo("Success", f"Supplier '{supplier.name}' created successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to create supplier") \ No newline at end of file diff --git a/src/ui/user_management_dialog.py b/src/ui/user_management_dialog.py new file mode 100644 index 0000000..60dc6f9 --- /dev/null +++ b/src/ui/user_management_dialog.py @@ -0,0 +1,460 @@ +import customtkinter as ctk +from tkinter import messagebox +from typing import Optional +from src.auth import AuthManager + + +class UserManagementDialog(ctk.CTkToplevel): + def __init__(self, parent, auth_manager: AuthManager): + super().__init__(parent) + self.auth_manager = auth_manager + self.selected_user = None + self.selected_groups = [] + + self.title("User Management") + self.geometry("800x600") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + self.load_users() + self.load_groups() + + def setup_ui(self): + # Title + ctk.CTkLabel(self, text="User Management", font=("Arial", 16, "bold")).pack(pady=10) + + # Main frame + main_frame = ctk.CTkFrame(self) + main_frame.pack(fill="both", expand=True, padx=20, pady=10) + + # Users list frame + users_frame = ctk.CTkFrame(main_frame) + users_frame.pack(side="left", fill="both", expand=True, padx=(0, 10)) + + ctk.CTkLabel(users_frame, text="Users", font=("Arial", 14, "bold")).pack(pady=5) + + # Users list + self.users_listbox = ctk.CTkScrollableFrame(users_frame, height=300) + self.users_listbox.pack(fill="both", expand=True, padx=10, pady=10) + + # User buttons + user_buttons_frame = ctk.CTkFrame(users_frame) + user_buttons_frame.pack(fill="x", padx=10, pady=5) + + ctk.CTkButton(user_buttons_frame, text="Add User", command=self.add_user).pack(side="left", padx=5) + ctk.CTkButton(user_buttons_frame, text="Edit User", command=self.edit_user).pack(side="left", padx=5) + ctk.CTkButton(user_buttons_frame, text="Delete User", command=self.delete_user).pack(side="left", padx=5) + + # Groups list frame + groups_frame = ctk.CTkFrame(main_frame) + groups_frame.pack(side="right", fill="both", expand=True, padx=(10, 0)) + + ctk.CTkLabel(groups_frame, text="Groups", font=("Arial", 14, "bold")).pack(pady=5) + + # Groups list + self.groups_listbox = ctk.CTkScrollableFrame(groups_frame, height=300) + self.groups_listbox.pack(fill="both", expand=True, padx=10, pady=10) + + # Group buttons + group_buttons_frame = ctk.CTkFrame(groups_frame) + group_buttons_frame.pack(fill="x", padx=10, pady=5) + + ctk.CTkButton(group_buttons_frame, text="Add Group", command=self.add_group).pack(side="left", padx=5) + ctk.CTkButton(group_buttons_frame, text="Edit Group", command=self.edit_group).pack(side="left", padx=5) + ctk.CTkButton(group_buttons_frame, text="Delete Group", command=self.delete_group).pack(side="left", padx=5) + + # Assign/Unassign buttons + assign_frame = ctk.CTkFrame(self) + assign_frame.pack(fill="x", padx=20, pady=10) + + ctk.CTkButton(assign_frame, text="Assign to Group", command=self.assign_to_group).pack(side="left", padx=5) + ctk.CTkButton(assign_frame, text="Unassign from Group", command=self.unassign_from_group).pack(side="left", padx=5) + + # Close button + ctk.CTkButton(self, text="Close", command=self.destroy).pack(pady=10) + + def load_users(self): + # Clear existing widgets + for widget in self.users_listbox.winfo_children(): + widget.destroy() + + users = self.auth_manager.get_all_users() + self.user_widgets = [] + + for user in users: + user_frame = ctk.CTkFrame(self.users_listbox) + user_frame.pack(fill="x", padx=5, pady=2) + + # Get user groups + user_groups = self.auth_manager.get_user_groups(user['id']) + group_names = [group['name'] for group in user_groups] + groups_text = f" ({', '.join(group_names)})" if group_names else " (No groups)" + admin_text = " (Admin)" if user['is_admin'] else "" + + user_label = ctk.CTkLabel(user_frame, text=f"{user['username']}{admin_text}{groups_text}", wraplength=250, anchor="w") + user_label.pack(side="left", padx=10, pady=5) + + # Bind click event + user_frame.bind("", lambda e, u=user: self.select_user(u)) + user_label.bind("", lambda e, u=user: self.select_user(u)) + + self.user_widgets.append((user_frame, user_label, user)) + + def load_groups(self): + # Clear existing widgets + for widget in self.groups_listbox.winfo_children(): + widget.destroy() + + groups = self.auth_manager.get_all_groups() + self.group_widgets = [] + + for group in groups: + group_frame = ctk.CTkFrame(self.groups_listbox) + group_frame.pack(fill="x", padx=5, pady=2) + + group_label = ctk.CTkLabel(group_frame, text=group['name']) + group_label.pack(side="left", padx=10, pady=5) + + # Bind click event + group_frame.bind("", lambda e, g=group: self.select_group(g)) + group_label.bind("", lambda e, g=group: self.select_group(g)) + + self.group_widgets.append((group_frame, group_label, group)) + + def select_user(self, user): + self.selected_user = user + # Update visual selection + for widget_frame, widget_label, widget_user in self.user_widgets: + if widget_user == user: + widget_frame.configure(fg_color="blue") + widget_label.configure(text_color="white") + else: + widget_frame.configure(fg_color="transparent") + widget_label.configure(text_color="black") + + def refresh_user_list(self): + """Refresh the user list to show updated group memberships""" + self.load_users() + + def select_group(self, group): + self.selected_group = group + # Update visual selection + for widget_frame, widget_label, widget_group in self.group_widgets: + if widget_group == group: + widget_frame.configure(fg_color="blue") + widget_label.configure(text_color="white") + else: + widget_frame.configure(fg_color="transparent") + widget_label.configure(text_color="black") + + def add_user(self): + dialog = AddUserDialog(self, self.auth_manager) + self.wait_window(dialog) + self.load_users() + + def edit_user(self): + if not self.selected_user: + messagebox.showerror("Error", "Please select a user to edit") + return + + dialog = EditUserDialog(self, self.auth_manager, self.selected_user) + self.wait_window(dialog) + self.load_users() + + def delete_user(self): + if not self.selected_user: + messagebox.showerror("Error", "Please select a user to delete") + return + + if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete user '{self.selected_user['username']}'?"): + if self.auth_manager.delete_user(self.selected_user['id']): + messagebox.showinfo("Success", f"User '{self.selected_user['username']}' deleted successfully") + self.selected_user = None + self.load_users() + else: + messagebox.showerror("Error", "Failed to delete user") + + def add_group(self): + dialog = AddGroupDialog(self, self.auth_manager) + self.wait_window(dialog) + self.load_groups() + + def edit_group(self): + if not hasattr(self, 'selected_group'): + messagebox.showerror("Error", "Please select a group to edit") + return + + dialog = EditGroupDialog(self, self.auth_manager, self.selected_group) + self.wait_window(dialog) + self.load_groups() + + def delete_group(self): + if not hasattr(self, 'selected_group'): + messagebox.showerror("Error", "Please select a group to delete") + return + + if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete group '{self.selected_group['name']}'?"): + if self.auth_manager.delete_group(self.selected_group['id']): + messagebox.showinfo("Success", f"Group '{self.selected_group['name']}' deleted successfully") + delattr(self, 'selected_group') + self.load_groups() + else: + messagebox.showerror("Error", "Failed to delete group") + + def assign_to_group(self): + if not self.selected_user: + messagebox.showerror("Error", "Please select a user") + return + + if not hasattr(self, 'selected_group'): + messagebox.showerror("Error", "Please select a group") + return + + if self.auth_manager.add_user_to_group(self.selected_user['id'], self.selected_group['id']): + messagebox.showinfo("Success", f"User '{self.selected_user['username']}' assigned to group '{self.selected_group['name']}'") + self.refresh_user_list() + else: + messagebox.showerror("Error", "Failed to assign user to group") + + def unassign_from_group(self): + if not self.selected_user: + messagebox.showerror("Error", "Please select a user") + return + + if not hasattr(self, 'selected_group'): + messagebox.showerror("Error", "Please select a group") + return + + if self.auth_manager.remove_user_from_group(self.selected_user['id'], self.selected_group['id']): + messagebox.showinfo("Success", f"User '{self.selected_user['username']}' unassigned from group '{self.selected_group['name']}'") + self.refresh_user_list() + else: + messagebox.showerror("Error", "Failed to unassign user from group") + + +class AddUserDialog(ctk.CTkToplevel): + def __init__(self, parent, auth_manager: AuthManager): + super().__init__(parent) + self.auth_manager = auth_manager + + self.title("Add User") + self.geometry("400x300") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + + def setup_ui(self): + # Title + ctk.CTkLabel(self, text="Add New User", font=("Arial", 16, "bold")).pack(pady=10) + + # Username + ctk.CTkLabel(self, text="Username:").pack(anchor="w", padx=20, pady=(10, 0)) + self.username_entry = ctk.CTkEntry(self, width=300) + self.username_entry.pack(pady=5) + + # Password + ctk.CTkLabel(self, text="Password:").pack(anchor="w", padx=20, pady=(10, 0)) + self.password_entry = ctk.CTkEntry(self, width=300, show="*") + self.password_entry.pack(pady=5) + + # Confirm Password + ctk.CTkLabel(self, text="Confirm Password:").pack(anchor="w", padx=20, pady=(10, 0)) + self.confirm_password_entry = ctk.CTkEntry(self, width=300, show="*") + self.confirm_password_entry.pack(pady=5) + + # Admin checkbox + self.is_admin_var = ctk.BooleanVar() + ctk.CTkCheckBox(self, text="Admin User", variable=self.is_admin_var).pack(pady=10) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Save", command=self.save_user).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + def save_user(self): + username = self.username_entry.get().strip() + password = self.password_entry.get() + confirm_password = self.confirm_password_entry.get() + is_admin = self.is_admin_var.get() + + if not username: + messagebox.showerror("Error", "Please enter a username") + return + + if not password: + messagebox.showerror("Error", "Please enter a password") + return + + if password != confirm_password: + messagebox.showerror("Error", "Passwords do not match") + return + + try: + self.auth_manager.create_user(username, password, is_admin) + messagebox.showinfo("Success", f"User '{username}' created successfully") + self.destroy() + except Exception as e: + messagebox.showerror("Error", f"Failed to create user: {str(e)}") + + +class EditUserDialog(ctk.CTkToplevel): + def __init__(self, parent, auth_manager: AuthManager, user): + super().__init__(parent) + self.auth_manager = auth_manager + self.user = user + + self.title("Edit User") + self.geometry("400x250") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + + def setup_ui(self): + # Title + ctk.CTkLabel(self, text="Edit User", font=("Arial", 16, "bold")).pack(pady=10) + + # Username + ctk.CTkLabel(self, text="Username:").pack(anchor="w", padx=20, pady=(10, 0)) + self.username_entry = ctk.CTkEntry(self, width=300) + self.username_entry.insert(0, self.user['username']) + self.username_entry.pack(pady=5) + + # Admin checkbox + self.is_admin_var = ctk.BooleanVar(value=self.user['is_admin']) + ctk.CTkCheckBox(self, text="Admin User", variable=self.is_admin_var).pack(pady=10) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Save", command=self.save_user).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + def save_user(self): + username = self.username_entry.get().strip() + is_admin = self.is_admin_var.get() + + if not username: + messagebox.showerror("Error", "Please enter a username") + return + + # Note: In a real application, you would update the user in the database + # For now, we'll just show a message + messagebox.showinfo("Info", "User update functionality would be implemented in a full application") + self.destroy() + + +class AddGroupDialog(ctk.CTkToplevel): + def __init__(self, parent, auth_manager: AuthManager): + super().__init__(parent) + self.auth_manager = auth_manager + + self.title("Add Group") + self.geometry("400x300") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + + def setup_ui(self): + # Title + ctk.CTkLabel(self, text="Add New Group", font=("Arial", 16, "bold")).pack(pady=10) + + # Group name + ctk.CTkLabel(self, text="Group Name:").pack(anchor="w", padx=20, pady=(10, 0)) + self.name_entry = ctk.CTkEntry(self, width=300) + self.name_entry.pack(pady=5) + + # Permissions + ctk.CTkLabel(self, text="Permissions (comma separated):").pack(anchor="w", padx=20, pady=(10, 0)) + self.permissions_entry = ctk.CTkEntry(self, width=300) + self.permissions_entry.pack(pady=5) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Save", command=self.save_group).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + def save_group(self): + name = self.name_entry.get().strip() + permissions = self.permissions_entry.get().strip() + + if not name: + messagebox.showerror("Error", "Please enter a group name") + return + + permissions_list = [p.strip() for p in permissions.split(",")] if permissions else [] + + if self.auth_manager.create_group(name, permissions_list): + messagebox.showinfo("Success", f"Group '{name}' created successfully") + self.destroy() + else: + messagebox.showerror("Error", "Failed to create group") + + +class EditGroupDialog(ctk.CTkToplevel): + def __init__(self, parent, auth_manager: AuthManager, group): + super().__init__(parent) + self.auth_manager = auth_manager + self.group = group + + self.title("Edit Group") + self.geometry("400x300") + + # Make dialog modal + self.transient(parent) + self.grab_set() + + self.setup_ui() + + def setup_ui(self): + # Title + ctk.CTkLabel(self, text="Edit Group", font=("Arial", 16, "bold")).pack(pady=10) + + # Group name + ctk.CTkLabel(self, text="Group Name:").pack(anchor="w", padx=20, pady=(10, 0)) + self.name_entry = ctk.CTkEntry(self, width=300) + self.name_entry.insert(0, self.group['name']) + self.name_entry.pack(pady=5) + + # Permissions + ctk.CTkLabel(self, text="Permissions (comma separated):").pack(anchor="w", padx=20, pady=(10, 0)) + permissions_str = ",".join(self.group['permissions']) if self.group['permissions'] else "" + self.permissions_entry = ctk.CTkEntry(self, width=300) + self.permissions_entry.insert(0, permissions_str) + self.permissions_entry.pack(pady=5) + + # Buttons + button_frame = ctk.CTkFrame(self) + button_frame.pack(pady=20) + + ctk.CTkButton(button_frame, text="Save", command=self.save_group).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + def save_group(self): + name = self.name_entry.get().strip() + permissions = self.permissions_entry.get().strip() + + if not name: + messagebox.showerror("Error", "Please enter a group name") + return + + # Note: In a real application, you would update the group in the database + # For now, we'll just show a message + messagebox.showinfo("Info", "Group update functionality would be implemented in a full application") + self.destroy() \ No newline at end of file diff --git a/test_export.py b/test_export.py new file mode 100644 index 0000000..7eea227 --- /dev/null +++ b/test_export.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Test script to verify Excel export functionality +""" + +import os +import sys +from datetime import datetime + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from database import DatabaseManager +from services import ( + ProductService, SupplierService, CustomerService, + PurchaseOrderService, ManufacturingOrderService, + SalesOrderService, InventoryService +) + +def test_excel_exports(): + """Test all Excel export functionality""" + print("Testing Excel Export Functionality...") + + # Initialize database + db_manager = DatabaseManager() + + # Initialize services + product_service = ProductService(db_manager) + supplier_service = SupplierService(db_manager) + customer_service = CustomerService(db_manager) + purchase_service = PurchaseOrderService(db_manager) + manufacturing_service = ManufacturingOrderService(db_manager) + sales_service = SalesOrderService(db_manager) + inventory_service = InventoryService(db_manager) + + # Create test data + print("Creating test data...") + + # Create a product + product = product_service.create_product("Test Product", "Test Description", 100.0) + if product: + print(f"Created product: {product.name}") + + # Create a supplier + supplier = supplier_service.create_supplier("Test Supplier", "John Doe", "1234567890") + if supplier: + print(f"Created supplier: {supplier.name}") + + # Create a customer + customer = customer_service.create_customer("Test Customer", "Jane Doe", "0987654321") + if customer: + print(f"Created customer: {customer.name}") + + # Create inventory record + inventory = inventory_service.create_inventory_record(product.id, 50) + if inventory: + print(f"Created inventory record for product {product.id}") + + # Create purchase order + po = purchase_service.create_purchase_order(supplier.id) + if po: + print(f"Created purchase order: {po.id}") + + # Create manufacturing order + mo = manufacturing_service.create_manufacturing_order(product.id, 10) + if mo: + print(f"Created manufacturing order: {mo.id}") + + # Create sales order + so = sales_service.create_sales_order(customer.id) + if so: + print(f"Created sales order: {so.id}") + + # Test exports + print("\nTesting Excel exports...") + + try: + # Test purchase order export + po_filename = purchase_service.export_to_excel("test_purchase_orders.xlsx") + print(f"[OK] Purchase orders exported to: {po_filename}") + + # Test manufacturing order export + mo_filename = manufacturing_service.export_to_excel("test_manufacturing_orders.xlsx") + print(f"[OK] Manufacturing orders exported to: {mo_filename}") + + # Test sales order export + so_filename = sales_service.export_to_excel("test_sales_orders.xlsx") + print(f"[OK] Sales orders exported to: {so_filename}") + + # Test inventory export + inv_filename = inventory_service.export_to_excel("test_inventory.xlsx") + print(f"[OK] Inventory exported to: {inv_filename}") + + # Test stock movements export + sm_filename = inventory_service.export_stock_movements_to_excel("test_stock_movements.xlsx") + print(f"[OK] Stock movements exported to: {sm_filename}") + + print("\n[SUCCESS] All Excel export tests passed!") + + # Clean up test files + test_files = [po_filename, mo_filename, so_filename, inv_filename, sm_filename] + for file in test_files: + if os.path.exists(file): + os.remove(file) + print(f"Cleaned up: {file}") + + except Exception as e: + print(f"[ERROR] Error during export: {str(e)}") + return False + + return True + +if __name__ == "__main__": + success = test_excel_exports() + if success: + print("\n[SUCCESS] All tests completed successfully!") + else: + print("\n[ERROR] Some tests failed!") + sys.exit(1) \ No newline at end of file diff --git a/test_ui_and_exports.py b/test_ui_and_exports.py new file mode 100644 index 0000000..40a8d18 --- /dev/null +++ b/test_ui_and_exports.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Test script to verify the revamped UI and export features +""" + +import os +import sys +import subprocess +import time +import threading +from pathlib import Path + +def test_dependencies(): + """Test that all required dependencies are installed""" + print("Testing dependencies...") + + try: + import customtkinter + print("[OK] customtkinter installed") + except ImportError: + print("[FAIL] customtkinter not installed") + return False + + try: + import pandas + print("[OK] pandas installed") + except ImportError: + print("[FAIL] pandas not installed") + return False + + try: + import openpyxl + print("[OK] openpyxl installed") + except ImportError: + print("[FAIL] openpyxl not installed") + return False + + try: + import sqlalchemy + print("[OK] sqlalchemy installed") + except ImportError: + print("[FAIL] sqlalchemy not installed") + return False + + try: + import bcrypt + print("[OK] bcrypt installed") + except ImportError: + print("[FAIL] bcrypt not installed") + return False + + return True + +def test_exports(): + """Test Excel export functionality""" + print("\nTesting Excel export functionality...") + + # Import and run the existing test_export.py + try: + from test_export import test_excel_exports + test_excel_exports() + print("[OK] All Excel export tests passed") + return True + except Exception as e: + print(f"[FAIL] Excel export tests failed: {e}") + return False + +def test_ui_startup(): + """Test that the UI can start without errors""" + print("\nTesting UI startup...") + + try: + # Test that we can import the main modules + from src.app import ManufacturingApp + from src.ui.main_window import MainWindow + print("[OK] All UI modules import successfully") + + # Test database initialization + from src.database import DatabaseManager + db_manager = DatabaseManager() + db_manager.initialize_database() + print("[OK] Database initialization successful") + + return True + except Exception as e: + print(f"✗ UI startup test failed: {e}") + return False + +def main(): + """Run all tests""" + print("=== Testing Revamped Manufacturing App ===\n") + + success = True + success &= test_dependencies() + success &= test_ui_startup() + success &= test_exports() + + if success: + print("\n[SUCCESS] All tests passed! The revamped UI and export features are working correctly.") + else: + print("\n[ERROR] Some tests failed. Please check the output above.") + + return success + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) \ No newline at end of file