diff --git a/Machine-Backend/app/__init__.py b/Machine-Backend/app/__init__.py index e2f15e1..92345bb 100644 --- a/Machine-Backend/app/__init__.py +++ b/Machine-Backend/app/__init__.py @@ -53,6 +53,7 @@ def create_app(): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_ECHO'] = os.getenv('SQLALCHEMY_ECHO', 'False').lower() == 'true' + # Initialize extensions db.init_app(app) diff --git a/Machine-Backend/app/__pycache__/__init__.cpython-314.pyc b/Machine-Backend/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..c8b8f87 Binary files /dev/null and b/Machine-Backend/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/Machine-Backend/app/instance/backups/machines_backup_20251125_200112.db b/Machine-Backend/app/instance/backups/machines_backup_20251125_200112.db new file mode 100644 index 0000000..b0cd9ae Binary files /dev/null and b/Machine-Backend/app/instance/backups/machines_backup_20251125_200112.db differ diff --git a/Machine-Backend/app/instance/backups/machines_before_restore_20251125_200226.db b/Machine-Backend/app/instance/backups/machines_before_restore_20251125_200226.db new file mode 100644 index 0000000..b0cd9ae Binary files /dev/null and b/Machine-Backend/app/instance/backups/machines_before_restore_20251125_200226.db differ diff --git a/Machine-Backend/app/instance/machines.db b/Machine-Backend/app/instance/machines.db index eef373b..2e1d21a 100644 Binary files a/Machine-Backend/app/instance/machines.db and b/Machine-Backend/app/instance/machines.db differ diff --git a/Machine-Backend/app/models/__pycache__/__init__.cpython-314.pyc b/Machine-Backend/app/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..b936a02 Binary files /dev/null and b/Machine-Backend/app/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/Machine-Backend/app/models/__pycache__/models.cpython-313.pyc b/Machine-Backend/app/models/__pycache__/models.cpython-313.pyc index 1ae2095..4410592 100644 Binary files a/Machine-Backend/app/models/__pycache__/models.cpython-313.pyc and b/Machine-Backend/app/models/__pycache__/models.cpython-313.pyc differ diff --git a/Machine-Backend/app/models/__pycache__/models.cpython-314.pyc b/Machine-Backend/app/models/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..4d5aac9 Binary files /dev/null and b/Machine-Backend/app/models/__pycache__/models.cpython-314.pyc differ diff --git a/Machine-Backend/app/models/models.py b/Machine-Backend/app/models/models.py index b08e403..b94dcf8 100644 --- a/Machine-Backend/app/models/models.py +++ b/Machine-Backend/app/models/models.py @@ -4,7 +4,35 @@ import time import json from werkzeug.security import generate_password_hash, check_password_hash -# Machine Model +class RefillerMachine(db.Model): + __tablename__ = 'refiller_machines' + + id = db.Column(db.Integer, primary_key=True) + refiller_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + machine_id = db.Column(db.String(10), db.ForeignKey('machines.machine_id', ondelete='CASCADE'), nullable=False) + assigned_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + assigned_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + + # Relationships + assigner = db.relationship('User', foreign_keys=[assigned_by], backref='machine_assignments_made') + + # Unique constraint: one refiller can't be assigned to same machine twice + __table_args__ = ( + db.UniqueConstraint('refiller_id', 'machine_id', name='unique_refiller_machine'), + ) + + def to_dict(self): + return { + 'id': self.id, + 'refiller_id': self.refiller_id, + 'machine_id': self.machine_id, + 'assigned_at': self.assigned_at.strftime("%Y-%m-%d %H:%M:%S") if self.assigned_at else None, + 'assigned_by': self.assigned_by, + 'assigned_by_username': self.assigner.username if self.assigner else None + } + + +# Machine Model - UPDATED class Machine(db.Model): __tablename__ = 'machines' @@ -20,10 +48,37 @@ class Machine(db.Model): connection_status = db.Column(db.String(50), nullable=False) created_on = db.Column(db.String(20), nullable=False) password = db.Column(db.String(128), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + + # Relationships slots = db.relationship('VendingSlot', backref='machine', lazy=True) - client = db.relationship('User', backref='machines') + client = db.relationship('User', foreign_keys=[client_id], backref='client_machines') + creator = db.relationship('User', foreign_keys=[created_by], backref='created_machines') + + # ⭐ NEW: Many-to-many relationship with Refillers through RefillerMachine + assigned_refillers = db.relationship( + 'User', + secondary='refiller_machines', + primaryjoin='Machine.machine_id == RefillerMachine.machine_id', + secondaryjoin='and_(User.id == RefillerMachine.refiller_id, User.roles == "Refiller")', + backref='assigned_machines_rel', + viewonly=True + ) def to_dict(self): + # Get assigned refillers + refiller_assignments = RefillerMachine.query.filter_by(machine_id=self.machine_id).all() + assigned_refillers = [] + for assignment in refiller_assignments: + refiller = User.query.get(assignment.refiller_id) + if refiller: + assigned_refillers.append({ + 'id': refiller.id, + 'username': refiller.username, + 'email': refiller.email, + 'assigned_at': assignment.assigned_at.strftime("%Y-%m-%d %H:%M:%S") if assignment.assigned_at else None + }) + return { 'id': self.id, 'machine_id': self.machine_id, @@ -36,7 +91,10 @@ class Machine(db.Model): 'operation_status': self.operation_status, 'connection_status': self.connection_status, 'created_on': self.created_on, - 'password': self.password + 'password': self.password, + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None, + 'assigned_refillers': assigned_refillers # ⭐ NEW } def set_password(self, password): @@ -46,7 +104,7 @@ class Machine(db.Model): return check_password_hash(self.password, password) -# User Model - UPDATED with proper password hashing +# User Model - UPDATED WITH BOTH CLIENT AND MACHINE ASSIGNMENTS class User(db.Model): __tablename__ = 'users' @@ -57,7 +115,9 @@ class User(db.Model): contact = db.Column(db.String(20), nullable=False) roles = db.Column(db.String(50), nullable=False) user_status = db.Column(db.String(50), nullable=False) - password = db.Column(db.String(255), nullable=False) # Increased length for hash + password = db.Column(db.String(255), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Assigned to Client # File storage fields photo = db.Column(db.String(255), nullable=True) @@ -67,9 +127,27 @@ class User(db.Model): # Timestamps created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + # Relationships + creator = db.relationship('User', remote_side=[id], backref='created_users', foreign_keys=[created_by]) + assigned_client = db.relationship('User', remote_side=[id], backref='assigned_refillers', foreign_keys=[assigned_to]) def to_dict(self): """Convert user object to dictionary""" + # Get assigned machines for Refillers + assigned_machines = [] + if self.roles == 'Refiller': + machine_assignments = RefillerMachine.query.filter_by(refiller_id=self.id).all() + for assignment in machine_assignments: + machine = Machine.query.filter_by(machine_id=assignment.machine_id).first() + if machine: + assigned_machines.append({ + 'machine_id': machine.machine_id, + 'machine_model': machine.machine_model, + 'branch_name': machine.branch_name, + 'assigned_at': assignment.assigned_at.strftime("%Y-%m-%d %H:%M:%S") if assignment.assigned_at else None + }) + return { 'id': self.id, 'user_id': self.user_id, @@ -83,7 +161,12 @@ class User(db.Model): 'documents': json.loads(self.documents) if self.documents else [], 'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None, 'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None, - 'machines': [m.to_dict() for m in self.machines] if hasattr(self, 'machines') else [] + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None, + 'assigned_to': self.assigned_to, + 'assigned_to_username': self.assigned_client.username if self.assigned_client else None, + 'assigned_machines': assigned_machines, # ⭐ NEW + 'assigned_machines_count': len(assigned_machines) # ⭐ NEW } def set_password(self, password): @@ -125,6 +208,13 @@ class Product(db.Model): price = db.Column(db.Float, nullable=False) product_image = db.Column(db.String(255), nullable=False) created_date = db.Column(db.String(20), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + # NEW: Billing and expiration dates + billing_date = db.Column(db.DateTime, nullable=True) + expiration_date = db.Column(db.DateTime, nullable=True) + + # Relationship + creator = db.relationship('User', backref='created_products') def to_dict(self): return { @@ -133,7 +223,11 @@ class Product(db.Model): 'product_name': self.product_name, 'price': str(self.price), 'product_image': self.product_image, - 'created_date': self.created_date + 'created_date': self.created_date, + 'billing_date': self.billing_date.strftime("%Y-%m-%d") if self.billing_date else None, + 'expiration_date': self.expiration_date.strftime("%Y-%m-%d") if self.expiration_date else None, + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None } @@ -191,7 +285,9 @@ class Transaction(db.Model): 'return_amount': self.return_amount, 'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None } -# Add to your models.py + + +# Role Model class Role(db.Model): __tablename__ = 'roles' @@ -199,15 +295,260 @@ class Role(db.Model): name = db.Column(db.String(50), unique=True, nullable=False) description = db.Column(db.String(255)) permissions = db.Column(db.Text) # JSON string of permission IDs + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + # Relationship + creator = db.relationship('User', backref='created_roles') + def to_dict(self): return { 'id': self.id, 'name': self.name, 'description': self.description, 'permissions': json.loads(self.permissions) if self.permissions else [], + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None, 'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None, 'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None - } \ No newline at end of file + } + + +# Branch Model +class Branch(db.Model): + __tablename__ = 'branches' + + id = db.Column(db.Integer, primary_key=True) + branch_id = db.Column(db.String(50), unique=True, nullable=False) + code = db.Column(db.String(20), unique=True, nullable=False) + name = db.Column(db.String(100), nullable=False) + location = db.Column(db.String(100), nullable=False) + address = db.Column(db.Text, nullable=False) + contact = db.Column(db.String(20), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + # Relationship + creator = db.relationship('User', backref='created_branches') + + def to_dict(self): + return { + 'id': self.id, + 'branch_id': self.branch_id, + 'code': self.code, + 'name': self.name, + 'location': self.location, + 'address': self.address, + 'contact': self.contact, + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None, + 'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None, + 'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None + } + + @staticmethod + def generate_branch_id(): + """Generate unique branch ID with BR prefix""" + import uuid + while True: + branch_id = f"BR{uuid.uuid4().hex[:8].upper()}" + if not Branch.query.filter_by(branch_id=branch_id).first(): + return branch_id + +# Brand Model +class Brand(db.Model): + __tablename__ = 'brands' + + id = db.Column(db.Integer, primary_key=True) + brand_id = db.Column(db.String(50), unique=True, nullable=False) + name = db.Column(db.String(100), nullable=False) + branch_id = db.Column(db.String(50), db.ForeignKey('branches.branch_id'), nullable=False) + branch_name = db.Column(db.String(100), nullable=False) + image = db.Column(db.String(255), nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + # Relationships + branch = db.relationship('Branch', backref='brands', foreign_keys=[branch_id]) + creator = db.relationship('User', backref='created_brands') + + def to_dict(self): + return { + 'id': self.id, + 'brand_id': self.brand_id, + 'name': self.name, + 'branch_id': self.branch_id, + 'branch_name': self.branch_name, + 'image': self.image, + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None, + 'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None, + 'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None + } + + @staticmethod + def generate_brand_id(name): + """ + Generate brand ID from first 3 letters of name + 4-digit sequence + Example: CocaCola -> COC0001, COC0002, etc. + """ + import re + + # Extract only letters from name and get first 3 + letters_only = ''.join(filter(str.isalpha, name)).upper() + prefix = letters_only[:3].ljust(3, 'X') # Pad with X if less than 3 letters + + # Find the highest existing sequence number for this prefix + existing_brands = Brand.query.filter( + Brand.brand_id.like(f"{prefix}%") + ).all() + + if not existing_brands: + sequence = 1 + else: + # Extract sequence numbers and find max + sequences = [] + for brand in existing_brands: + try: + seq_part = brand.brand_id[3:] # Get part after prefix + if seq_part.isdigit(): + sequences.append(int(seq_part)) + except: + continue + + sequence = max(sequences) + 1 if sequences else 1 + + # Format: PREFIX + 4-digit sequence + brand_id = f"{prefix}{sequence:04d}" + + return brand_id + +# Category Model +class Category(db.Model): + __tablename__ = 'categories' + + id = db.Column(db.Integer, primary_key=True) + category_id = db.Column(db.String(50), unique=True, nullable=False) + name = db.Column(db.String(100), nullable=False) + image = db.Column(db.String(255), nullable=True) + brand_id = db.Column(db.String(50), db.ForeignKey('brands.brand_id'), nullable=False) + brand_name = db.Column(db.String(100), nullable=False) + branch_id = db.Column(db.String(50), db.ForeignKey('branches.branch_id'), nullable=False) + branch_name = db.Column(db.String(100), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + # Relationships + brand = db.relationship('Brand', backref='categories', foreign_keys=[brand_id]) + branch = db.relationship('Branch', backref='categories', foreign_keys=[branch_id]) + creator = db.relationship('User', backref='created_categories') + + def to_dict(self): + return { + 'id': self.id, + 'category_id': self.category_id, + 'name': self.name, + 'image': self.image, + 'brand_id': self.brand_id, + 'brand_name': self.brand_name, + 'branch_id': self.branch_id, + 'branch_name': self.branch_name, + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None, + 'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None, + 'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None + } + + @staticmethod + def generate_category_id(name): + """ + Generate category ID from first 3 letters of name + 4-digit sequence + Example: Beverages -> BEV0001, BEV0002, etc. + """ + import re + + # Extract only letters from name and get first 3 + letters_only = ''.join(filter(str.isalpha, name)).upper() + prefix = letters_only[:3].ljust(3, 'X') # Pad with X if less than 3 letters + + # Find the highest existing sequence number for this prefix + existing_categories = Category.query.filter( + Category.category_id.like(f"{prefix}%") + ).all() + + if not existing_categories: + sequence = 1 + else: + # Extract sequence numbers and find max + sequences = [] + for category in existing_categories: + try: + seq_part = category.category_id[3:] # Get part after prefix + if seq_part.isdigit(): + sequences.append(int(seq_part)) + except: + continue + + sequence = max(sequences) + 1 if sequences else 1 + + # Format: PREFIX + 4-digit sequence + category_id = f"{prefix}{sequence:04d}" + + return category_id + +class SubCategory(db.Model): + __tablename__ = 'sub_categories' + + id = db.Column(db.Integer, primary_key=True) + sub_category_id = db.Column(db.String(50), unique=True, nullable=False) + name = db.Column(db.String(100), nullable=False) + image = db.Column(db.String(255), nullable=True) + category_id = db.Column(db.String(50), db.ForeignKey('categories.category_id'), nullable=False) + category_name = db.Column(db.String(100), nullable=False) + brand_id = db.Column(db.String(50), db.ForeignKey('brands.brand_id'), nullable=False) + brand_name = db.Column(db.String(100), nullable=False) + branch_id = db.Column(db.String(50), db.ForeignKey('branches.branch_id'), nullable=False) + branch_name = db.Column(db.String(100), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + # Relationships + category = db.relationship('Category', backref='sub_categories', foreign_keys=[category_id]) + brand = db.relationship('Brand', backref='sub_categories', foreign_keys=[brand_id]) + branch = db.relationship('Branch', backref='sub_categories', foreign_keys=[branch_id]) + creator = db.relationship('User', backref='created_subcategories') + + def to_dict(self): + return { + 'id': self.id, + 'sub_category_id': self.sub_category_id, + 'name': self.name, + 'image': self.image, + 'category_id': self.category_id, + 'category_name': self.category_name, + 'brand_id': self.brand_id, + 'brand_name': self.brand_name, + 'branch_id': self.branch_id, + 'branch_name': self.branch_name, + 'created_by': self.created_by, + 'created_by_username': self.creator.username if self.creator else None, + 'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None, + 'updated_at': self.updated_at.strftime("%Y-%m-%d %H:%M:%S") if self.updated_at else None + } + + @staticmethod + def generate_sub_category_id(name): + letters_only = ''.join(filter(str.isalpha, name)).upper() + prefix = letters_only[:3].ljust(3, 'X') + existing = SubCategory.query.filter(SubCategory.sub_category_id.like(f"{prefix}%")).all() + if not existing: + sequence = 1 + else: + sequences = [int(s.sub_category_id[3:]) for s in existing if s.sub_category_id[3:].isdigit()] + sequence = max(sequences) + 1 if sequences else 1 + return f"{prefix}{sequence:04d}" \ No newline at end of file diff --git a/Machine-Backend/app/routes/__pycache__/__init__.cpython-314.pyc b/Machine-Backend/app/routes/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..0c6b2d0 Binary files /dev/null and b/Machine-Backend/app/routes/__pycache__/__init__.cpython-314.pyc differ diff --git a/Machine-Backend/app/routes/__pycache__/routes.cpython-313.pyc b/Machine-Backend/app/routes/__pycache__/routes.cpython-313.pyc index 4707755..280d331 100644 Binary files a/Machine-Backend/app/routes/__pycache__/routes.cpython-313.pyc and b/Machine-Backend/app/routes/__pycache__/routes.cpython-313.pyc differ diff --git a/Machine-Backend/app/routes/__pycache__/routes.cpython-314.pyc b/Machine-Backend/app/routes/__pycache__/routes.cpython-314.pyc new file mode 100644 index 0000000..afff4f3 Binary files /dev/null and b/Machine-Backend/app/routes/__pycache__/routes.cpython-314.pyc differ diff --git a/Machine-Backend/app/routes/routes.py b/Machine-Backend/app/routes/routes.py index 138e3e9..28d01da 100644 --- a/Machine-Backend/app/routes/routes.py +++ b/Machine-Backend/app/routes/routes.py @@ -1,7 +1,7 @@ from app import db # Add this if not already there from sqlalchemy import func, extract # Add this from flask import Blueprint, request, jsonify, send_from_directory -from app.services.services import MachineService, UserService, ProductService, serial_service, TransactionService, RoleService +from app.services.services import MachineService, UserService, ProductService, serial_service, TransactionService, RoleService, BranchService, BrandService, CategoryService, SubCategoryService from app.models.models import Machine, User, Transaction, Product, VendingSlot, Role import os import hashlib @@ -156,8 +156,12 @@ def add_machine(): return jsonify({"error": "client_id is required"}), 400 print(f"✓ Admin creating machine for client_id: {data['client_id']}") + # ADD CREATED_BY + data['created_by'] = current_user.id + new_machine = MachineService.create_machine(data) print(f"✓ Machine created: {new_machine.machine_id} for client_id: {new_machine.client_id}") + print(f"✓ Created by: {current_user.username} (ID: {current_user.id})") return jsonify(new_machine.to_dict()), 201 @@ -187,6 +191,383 @@ def delete_machine(id): except Exception as e: return jsonify({"error": str(e)}), 500 +@bp.route('/refillers//machines', methods=['GET']) +def get_refiller_machines(refiller_id): + """Get all machines assigned to a Refiller""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Check permissions + is_admin = current_user.roles in ['Management', 'SuperAdmin', 'Admin'] + is_own_data = current_user.id == refiller_id + is_client_checking_own_refiller = False + + if current_user.roles == 'Client': + # Client can view their assigned Refiller's machines + refiller = User.query.get(refiller_id) + if refiller and refiller.assigned_to == current_user.id: + is_client_checking_own_refiller = True + + if not (is_admin or is_own_data or is_client_checking_own_refiller): + return jsonify({'error': 'Permission denied'}), 403 + + # Get machines + machines = UserService.get_refiller_machines(refiller_id) + + return jsonify([machine.to_dict() for machine in machines]), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/refillers//machines', methods=['POST']) +def assign_machines_to_refiller(refiller_id): + """Assign machines to a Refiller""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only admins can assign machines + if current_user.roles not in ['Management', 'SuperAdmin', 'Admin']: + return jsonify({'error': 'Permission denied'}), 403 + + data = request.json + machine_ids = data.get('machine_ids', []) + + if not machine_ids: + return jsonify({'error': 'machine_ids array required'}), 400 + + # Assign machines + assignments = UserService.assign_machines_to_refiller( + refiller_id, + machine_ids, + current_user.id + ) + + return jsonify({ + 'message': f'Successfully assigned {len(assignments)} machines', + 'assignments': [a.to_dict() for a in assignments] + }), 200 + + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/refillers//machines', methods=['PUT']) +def update_refiller_machines(refiller_id): + """Update all machine assignments for a Refiller (replaces existing)""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only admins can update assignments + if current_user.roles not in ['Management', 'SuperAdmin', 'Admin']: + return jsonify({'error': 'Permission denied'}), 403 + + data = request.json + machine_ids = data.get('machine_ids', []) + + # Update machines (replaces all existing) + assignments = UserService.update_refiller_machines( + refiller_id, + machine_ids, + current_user.id + ) + + return jsonify({ + 'message': f'Successfully updated machine assignments', + 'assignments': [a.to_dict() for a in assignments], + 'count': len(assignments) + }), 200 + + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/refillers//machines/', methods=['DELETE']) +def remove_machine_from_refiller(refiller_id, machine_id): + """Remove a machine assignment from a Refiller""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only admins can remove assignments + if current_user.roles not in ['Management', 'SuperAdmin', 'Admin']: + return jsonify({'error': 'Permission denied'}), 403 + + success = UserService.remove_machine_from_refiller(refiller_id, machine_id) + + if success: + return jsonify({ + 'message': f'Successfully removed machine {machine_id} from refiller' + }), 200 + else: + return jsonify({'error': 'Assignment not found'}), 404 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +# ============================================================================ +# MACHINE-CENTRIC ENDPOINTS +# ============================================================================ + +@bp.route('/machines//refillers', methods=['GET']) +def get_machine_refillers(machine_id): + """Get all Refillers assigned to a Machine""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Admins and machine owner can view + machine = Machine.query.filter_by(machine_id=machine_id).first() + if not machine: + return jsonify({'error': 'Machine not found'}), 404 + + is_admin = current_user.roles in ['Management', 'SuperAdmin', 'Admin'] + is_owner = machine.client_id == current_user.id + + if not (is_admin or is_owner): + return jsonify({'error': 'Permission denied'}), 403 + + # Get refillers + refillers = MachineService.get_machine_refillers(machine_id) + + return jsonify([refiller.to_dict() for refiller in refillers]), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/machines//refillers', methods=['POST']) +def assign_refillers_to_machine(machine_id): + """Assign Refillers to a Machine""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only admins can assign refillers to machines + if current_user.roles not in ['Management', 'SuperAdmin', 'Admin']: + return jsonify({'error': 'Permission denied'}), 403 + + data = request.json + refiller_ids = data.get('refiller_ids', []) + + if not refiller_ids: + return jsonify({'error': 'refiller_ids array required'}), 400 + + # Assign refillers + assignments = MachineService.assign_refillers_to_machine( + machine_id, + refiller_ids, + current_user.id + ) + + return jsonify({ + 'message': f'Successfully assigned {len(assignments)} refillers', + 'assignments': [a.to_dict() for a in assignments] + }), 200 + + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +# ============================================================================ +# UTILITY ENDPOINTS +# ============================================================================ + +@bp.route('/clients//machines', methods=['GET']) +def get_client_machines(client_id): + """Get all machines belonging to a Client (for assignment dropdown)""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Admins or the client themselves can view + is_admin = current_user.roles in ['Management', 'SuperAdmin', 'Admin'] + is_own_data = current_user.id == client_id + + if not (is_admin or is_own_data): + return jsonify({'error': 'Permission denied'}), 403 + + # Get client's machines + machines = Machine.query.filter_by(client_id=client_id).all() + + return jsonify([{ + 'id': m.id, + 'machine_id': m.machine_id, + 'machine_model': m.machine_model, + 'machine_type': m.machine_type, + 'branch_name': m.branch_name, + 'operation_status': m.operation_status + } for m in machines]), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/refillers//available-machines', methods=['GET']) +def get_available_machines_for_refiller(refiller_id): + """Get machines available to assign to a Refiller (from their assigned Client)""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only admins can view + if current_user.roles not in ['Management', 'SuperAdmin', 'Admin']: + return jsonify({'error': 'Permission denied'}), 403 + + # Get refiller + refiller = User.query.get_or_404(refiller_id) + + if refiller.roles != 'Refiller': + return jsonify({'error': 'User is not a Refiller'}), 400 + + # If refiller is assigned to a client, get that client's machines + # Otherwise, get all machines + if refiller.assigned_to: + machines = Machine.query.filter_by(client_id=refiller.assigned_to).all() + message = f"Machines from assigned client" + else: + machines = Machine.query.all() + message = "All machines (no client assigned)" + + return jsonify({ + 'message': message, + 'machines': [{ + 'id': m.id, + 'machine_id': m.machine_id, + 'machine_model': m.machine_model, + 'machine_type': m.machine_type, + 'branch_name': m.branch_name, + 'client_name': m.client.username if m.client else m.client_name, + 'operation_status': m.operation_status + } for m in machines] + }), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +@bp.route('/clients', methods=['GET']) +def get_clients(): + """Get all clients for assignment dropdown""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Management, SuperAdmin, and Admin can see clients + if current_user.roles not in ['Management', 'SuperAdmin', 'Admin']: + return jsonify({'error': 'Permission denied'}), 403 + + # Get all users with Client role + clients = User.query.filter_by(roles='Client', user_status='Active').all() + + client_list = [{ + 'id': client.id, + 'user_id': client.user_id, + 'username': client.username, + 'email': client.email, + 'contact': client.contact + } for client in clients] + + print(f"✓ Retrieved {len(client_list)} active clients") + + return jsonify(client_list), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/users//assigned-refillers', methods=['GET']) +def get_assigned_refillers(user_id): + """Get all refillers assigned to a specific client""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Check permissions + is_admin = current_user.roles in ['Management', 'SuperAdmin', 'Admin'] + is_own_data = current_user.id == user_id + + if not (is_admin or is_own_data): + return jsonify({'error': 'Permission denied'}), 403 + + # Get user and validate it's a Client + user = User.query.get_or_404(user_id) + if user.roles != 'Client': + return jsonify({'error': 'User is not a Client'}), 400 + + # Get all refillers assigned to this client + refillers = User.query.filter_by( + assigned_to=user_id, + roles='Refiller', + user_status='Active' + ).all() + + refiller_list = [{ + 'id': refiller.id, + 'user_id': refiller.user_id, + 'username': refiller.username, + 'email': refiller.email, + 'contact': refiller.contact, + 'created_by_username': refiller.creator.username if refiller.creator else None + } for refiller in refillers] + + print(f"✓ Client {user.username} has {len(refiller_list)} assigned refillers") + + return jsonify(refiller_list), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + # Updated User Routes in routes.py # In your routes.py file @@ -205,9 +586,30 @@ def add_users(): print("CREATE USER REQUEST") print("=" * 60) + # Get current user + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({"error": "Authentication required"}), 401 + try: # Get form data data = request.form.to_dict() + + # ADD CREATED_BY + data['created_by'] = current_user.id + + # ⭐ NEW: Handle assigned_to + if 'assigned_to' in data: + assigned_to = data.get('assigned_to') + if assigned_to: + try: + data['assigned_to'] = int(assigned_to) + except (ValueError, TypeError): + return jsonify({"error": "Invalid assigned_to value"}), 400 + else: + data['assigned_to'] = None + print("Form data keys:", list(data.keys())) print("Form values:", data) @@ -221,10 +623,6 @@ def add_users(): print(f" Photo: {photo_file.filename if photo_file else None}") print(f" Logo: {logo_file.filename if logo_file else None}") print(f" Documents: {len(document_files)} files") - if document_files: - for i, f in enumerate(document_files): - print(f" {i+1}. {f.filename}") - print(f" Metadata: {documents_metadata}") # Call service print("\nCalling UserService.create_user...") @@ -236,7 +634,9 @@ def add_users(): documents_metadata ) - print("✓ User created successfully!") + print(f"✓ User created successfully by: {current_user.username}") + if new_user.assigned_to: + print(f"✓ Assigned to Client ID: {new_user.assigned_to}") print("=" * 60 + "\n") return jsonify(new_user.to_dict()), 201 @@ -258,27 +658,40 @@ def add_users(): return jsonify({"error": f"Server error: {str(e)}"}), 500 + +# UPDATE the existing update_user endpoint to handle assigned_to @bp.route('/users/', methods=['PUT']) def update_user(id): try: data = request.form.to_dict() + # ⭐ NEW: Handle assigned_to + if 'assigned_to' in data: + assigned_to = data.get('assigned_to') + if assigned_to: + try: + data['assigned_to'] = int(assigned_to) + except (ValueError, TypeError): + return jsonify({"error": "Invalid assigned_to value"}), 400 + else: + data['assigned_to'] = None + # Get uploaded files photo_file = request.files.get('photo') logo_file = request.files.get('company_logo') document_files = request.files.getlist('documents') - # Get document metadata - THIS IS NEW + # Get document metadata documents_metadata = request.form.get('documents_metadata') - # Pass metadata to service - UPDATED PARAMETER LIST + # Pass metadata to service updated_user = UserService.update_user( id, data, photo_file, logo_file, document_files, - documents_metadata # NEW PARAMETER + documents_metadata ) return jsonify(updated_user.to_dict()) except ValueError as e: @@ -286,6 +699,8 @@ def update_user(id): except Exception as e: return jsonify({"error": str(e)}), 500 + + @bp.route('/users/', methods=['DELETE']) def delete_user(id): try: @@ -334,10 +749,23 @@ def get_products(): @bp.route('/products', methods=['POST']) def add_product(): + # Get current user + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({"error": "Authentication required"}), 401 + try: - data = request.form + data = request.form.to_dict() + + # ADD CREATED_BY + data['created_by'] = current_user.id + file = request.files.get('product_image') new_product = ProductService.create_product(data, file) + + print(f"✓ Product created by: {current_user.username}") + return jsonify(new_product.to_dict()), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 @@ -852,7 +1280,6 @@ def get_current_user_from_token(): except: return None - from flask import jsonify, request from sqlalchemy import func, extract from datetime import datetime @@ -2080,18 +2507,26 @@ def get_roles(): @bp.route('/roles', methods=['POST']) def create_role(): """Create new role""" + # Get current user + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Management and SuperAdmin can create roles + if current_user.roles not in ['Management', 'SuperAdmin', 'Admin']: + return jsonify({'error': 'Permission denied'}), 403 + try: - current_user = get_current_user_from_token() - if not current_user: - return jsonify({'error': 'Authentication required'}), 401 - - # Only Management and SuperAdmin can create roles - if current_user.roles not in ['Management', 'SuperAdmin','Admin']: - return jsonify({'error': 'Permission denied'}), 403 - data = request.json + + # ADD CREATED_BY + data['created_by'] = current_user.id + new_role = RoleService.create_role(data) + print(f"✓ Role created by: {current_user.username}") + return jsonify(new_role.to_dict()), 201 except ValueError as e: @@ -2185,4 +2620,750 @@ def get_available_permissions(): return jsonify(permissions), 200 except Exception as e: - return jsonify({'error': str(e)}), 500 \ No newline at end of file + return jsonify({'error': str(e)}), 500 + +@bp.route('/branches', methods=['GET']) +def get_branches(): + """Get all branches""" + try: + # Get current user from token + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import BranchService + branches = BranchService.get_all_branches() + + return jsonify([branch.to_dict() for branch in branches]), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/branches/', methods=['GET']) +def get_branch(branch_id): + """Get single branch by ID""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import BranchService + branch = BranchService.get_branch_by_id(branch_id) + + return jsonify(branch.to_dict()), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 404 + + +@bp.route('/branches', methods=['POST']) +def create_branch(): + print("\n" + "=" * 60) + print("CREATE BRANCH REQUEST") + print("=" * 60) + + # Get current user + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can create branches + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + try: + data = request.json + + # ADD CREATED_BY + data['created_by'] = current_user.id + + print(f"User: {current_user.username} ({current_user.roles})") + print(f"Data: {data}") + + new_branch = BranchService.create_branch(data) + + print(f"✓ Branch created successfully by: {current_user.username}") + print("=" * 60 + "\n") + + return jsonify(new_branch.to_dict()), 201 + + except ValueError as e: + print(f"\n✗ Validation Error: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 400 + + except Exception as e: + print(f"\n✗ SERVER ERROR") + print(f"Error type: {type(e).__name__}") + print(f"Error message: {str(e)}") + + import traceback + print("\nFull traceback:") + traceback.print_exc() + print("=" * 60 + "\n") + + return jsonify({'error': f'Server error: {str(e)}'}), 500 + +@bp.route('/branches/', methods=['PUT']) +def update_branch(branch_id): + """Update existing branch""" + print("\n" + "=" * 60) + print(f"UPDATE BRANCH REQUEST - ID: {branch_id}") + print("=" * 60) + + try: + # Get current user from token + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can update branches + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + data = request.json + + print(f"User: {current_user.username} ({current_user.roles})") + print(f"Data: {data}") + + from app.services.services import BranchService + updated_branch = BranchService.update_branch(branch_id, data) + + print(f"✓ Branch updated successfully: {updated_branch.branch_id}") + print("=" * 60 + "\n") + + return jsonify(updated_branch.to_dict()), 200 + + except ValueError as e: + print(f"\n✗ Validation Error: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 400 + + except Exception as e: + print(f"\n✗ SERVER ERROR") + print(f"Error type: {type(e).__name__}") + print(f"Error message: {str(e)}") + + import traceback + print("\nFull traceback:") + traceback.print_exc() + print("=" * 60 + "\n") + + return jsonify({'error': f'Server error: {str(e)}'}), 500 + + +@bp.route('/branches/', methods=['DELETE']) +def delete_branch(branch_id): + """Delete branch""" + print("\n" + "=" * 60) + print(f"DELETE BRANCH REQUEST - ID: {branch_id}") + print("=" * 60) + + try: + # Get current user from token + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can delete branches + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + print(f"User: {current_user.username} ({current_user.roles})") + + from app.services.services import BranchService + BranchService.delete_branch(branch_id) + + print(f"✓ Branch deleted successfully") + print("=" * 60 + "\n") + + return jsonify({'message': 'Branch deleted successfully'}), 200 + + except Exception as e: + print(f"\n✗ ERROR: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 500 + + +@bp.route('/branches/search', methods=['GET']) +def search_branches(): + """Search branches by query""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + query = request.args.get('q', '') + + if not query: + return jsonify({'error': 'Search query required'}), 400 + + from app.services.services import BranchService + branches = BranchService.search_branches(query) + + return jsonify([branch.to_dict() for branch in branches]), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# Brand Management Routes +@bp.route('/brands', methods=['GET']) +def get_brands(): + """Get all brands""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import BrandService + brands = BrandService.get_all_brands() + + return jsonify([brand.to_dict() for brand in brands]), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/brands/', methods=['GET']) +def get_brand(brand_id): + """Get single brand by ID""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import BrandService + brand = BrandService.get_brand_by_id(brand_id) + + return jsonify(brand.to_dict()), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 404 + + +@bp.route('/brands', methods=['POST']) +def create_brand(): + print("\n" + "=" * 60) + print("CREATE BRAND REQUEST") + print("=" * 60) + + # Get current user + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can create brands + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + try: + # Get form data + data = request.form.to_dict() + + # ADD CREATED_BY + data['created_by'] = current_user.id + + # Get image file + image_file = request.files.get('image') + + print(f"User: {current_user.username} ({current_user.roles})") + print(f"Data: {data}") + print(f"Image: {image_file.filename if image_file else 'None'}") + + new_brand = BrandService.create_brand(data, image_file) + + print(f"✓ Brand created successfully by: {current_user.username}") + print("=" * 60 + "\n") + + return jsonify(new_brand.to_dict()), 201 + + except ValueError as e: + print(f"\n✗ Validation Error: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 400 + + except Exception as e: + print(f"\n✗ SERVER ERROR") + print(f"Error type: {type(e).__name__}") + print(f"Error message: {str(e)}") + + import traceback + print("\nFull traceback:") + traceback.print_exc() + print("=" * 60 + "\n") + + return jsonify({'error': f'Server error: {str(e)}'}), 500 + +@bp.route('/brands/', methods=['PUT']) +def update_brand(brand_id): + """Update existing brand""" + print("\n" + "=" * 60) + print(f"UPDATE BRAND REQUEST - ID: {brand_id}") + print("=" * 60) + + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can update brands + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + # Get form data + data = request.form.to_dict() + + # Get image file if provided + image_file = request.files.get('image') if 'image' in request.files else None + + print(f"User: {current_user.username} ({current_user.roles})") + print(f"Data: {data}") + print(f"Image: {image_file.filename if image_file else 'No change'}") + + from app.services.services import BrandService + updated_brand = BrandService.update_brand(brand_id, data, image_file) + + print(f"✓ Brand updated successfully: {updated_brand.brand_id}") + print("=" * 60 + "\n") + + return jsonify(updated_brand.to_dict()), 200 + + except ValueError as e: + print(f"\n✗ Validation Error: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 400 + + except Exception as e: + print(f"\n✗ SERVER ERROR") + print(f"Error type: {type(e).__name__}") + print(f"Error message: {str(e)}") + + import traceback + print("\nFull traceback:") + traceback.print_exc() + print("=" * 60 + "\n") + + return jsonify({'error': f'Server error: {str(e)}'}), 500 + + +@bp.route('/brands/', methods=['DELETE']) +def delete_brand(brand_id): + """Delete brand""" + print("\n" + "=" * 60) + print(f"DELETE BRAND REQUEST - ID: {brand_id}") + print("=" * 60) + + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can delete brands + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + print(f"User: {current_user.username} ({current_user.roles})") + + from app.services.services import BrandService + BrandService.delete_brand(brand_id) + + print(f"✓ Brand deleted successfully") + print("=" * 60 + "\n") + + return jsonify({'message': 'Brand deleted successfully'}), 200 + + except Exception as e: + print(f"\n✗ ERROR: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 500 + + +@bp.route('/brands/search', methods=['GET']) +def search_brands(): + """Search brands by query""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + query = request.args.get('q', '') + + if not query: + return jsonify({'error': 'Search query required'}), 400 + + from app.services.services import BrandService + brands = BrandService.search_brands(query) + + return jsonify([brand.to_dict() for brand in brands]), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@bp.route('/brands/by-branch/', methods=['GET']) +def get_brands_by_branch(branch_id): + """Get all brands for a specific branch""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import BrandService + brands = BrandService.get_brands_by_branch(branch_id) + + return jsonify([brand.to_dict() for brand in brands]), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +# Serve brand images +@bp.route('/uploads/brand_images/') +def serve_brand_image(filename): + return send_from_directory(os.path.join(bp.root_path, '..', 'Uploads', 'brand_images'), filename) + + +# Category Management Routes +@bp.route('/categories', methods=['GET']) +def get_categories(): + """Get all categories""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import CategoryService + categories = CategoryService.get_all_categories() + + return jsonify([category.to_dict() for category in categories]), 200 + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/categories/', methods=['GET']) +def get_category(category_id): + """Get single category by ID""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import CategoryService + category = CategoryService.get_category_by_id(category_id) + + return jsonify(category.to_dict()), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 404 + + +@bp.route('/categories', methods=['POST']) +def create_category(): + print("\n" + "=" * 60) + print("CREATE CATEGORY REQUEST") + print("=" * 60) + + # Get current user + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can create categories + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + try: + # Get form data + data = request.form.to_dict() + + # ADD CREATED_BY + data['created_by'] = current_user.id + + # Get image file + image_file = request.files.get('image') + + print(f"User: {current_user.username} ({current_user.roles})") + print(f"Data: {data}") + + new_category = CategoryService.create_category(data, image_file) + + print(f"✓ Category created successfully by: {current_user.username}") + print("=" * 60 + "\n") + + return jsonify(new_category.to_dict()), 201 + + except ValueError as e: + print(f"\n✗ Validation Error: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 400 + + except Exception as e: + print(f"\n✗ SERVER ERROR") + print(f"Error type: {type(e).__name__}") + print(f"Error message: {str(e)}") + + import traceback + print("\nFull traceback:") + traceback.print_exc() + print("=" * 60 + "\n") + + return jsonify({'error': f'Server error: {str(e)}'}), 500 + +@bp.route('/categories/', methods=['PUT']) +def update_category(category_id): + """Update existing category""" + print("\n" + "=" * 60) + print(f"UPDATE CATEGORY REQUEST - ID: {category_id}") + print("=" * 60) + + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can update categories + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + # Get form data + data = request.form.to_dict() + + # Get image file if provided + image_file = request.files.get('image') if 'image' in request.files else None + + print(f"User: {current_user.username} ({current_user.roles})") + print(f"Data: {data}") + print(f"Image: {image_file.filename if image_file else 'No change'}") + + from app.services.services import CategoryService + updated_category = CategoryService.update_category(category_id, data, image_file) + + print(f"✓ Category updated successfully: {updated_category.category_id}") + print("=" * 60 + "\n") + + return jsonify(updated_category.to_dict()), 200 + + except ValueError as e: + print(f"\n✗ Validation Error: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 400 + + except Exception as e: + print(f"\n✗ SERVER ERROR") + print(f"Error type: {type(e).__name__}") + print(f"Error message: {str(e)}") + + import traceback + print("\nFull traceback:") + traceback.print_exc() + print("=" * 60 + "\n") + + return jsonify({'error': f'Server error: {str(e)}'}), 500 + + +@bp.route('/categories/', methods=['DELETE']) +def delete_category(category_id): + """Delete category""" + print("\n" + "=" * 60) + print(f"DELETE CATEGORY REQUEST - ID: {category_id}") + print("=" * 60) + + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + # Only Admin, Management, and SuperAdmin can delete categories + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + print(f"User: {current_user.username} ({current_user.roles})") + + from app.services.services import CategoryService + CategoryService.delete_category(category_id) + + print(f"✓ Category deleted successfully") + print("=" * 60 + "\n") + + return jsonify({'message': 'Category deleted successfully'}), 200 + + except Exception as e: + print(f"\n✗ ERROR: {str(e)}") + print("=" * 60 + "\n") + return jsonify({'error': str(e)}), 500 + + +@bp.route('/categories/search', methods=['GET']) +def search_categories(): + """Search categories by query""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + query = request.args.get('q', '') + + if not query: + return jsonify({'error': 'Search query required'}), 400 + + from app.services.services import CategoryService + categories = CategoryService.search_categories(query) + + return jsonify([category.to_dict() for category in categories]), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@bp.route('/categories/by-brand/', methods=['GET']) +def get_categories_by_brand(brand_id): + """Get all categories for a specific brand""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import CategoryService + categories = CategoryService.get_categories_by_brand(brand_id) + + return jsonify([category.to_dict() for category in categories]), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@bp.route('/categories/by-branch/', methods=['GET']) +def get_categories_by_branch(branch_id): + """Get all categories for a specific branch""" + try: + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + from app.services.services import CategoryService + categories = CategoryService.get_categories_by_branch(branch_id) + + return jsonify([category.to_dict() for category in categories]), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +# Serve category images +@bp.route('/uploads/category_images/') +def serve_category_image(filename): + return send_from_directory(os.path.join(bp.root_path, '..', 'Uploads', 'category_images'), filename) + +@bp.route('/subcategories', methods=['GET']) +def get_subcategories(): + try: + current_user = get_current_user_from_token() + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + from app.services.services import SubCategoryService + return jsonify([s.to_dict() for s in SubCategoryService.get_all()]), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/subcategories/', methods=['GET']) +def get_subcategory(id): + try: + current_user = get_current_user_from_token() + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + from app.services.services import SubCategoryService + return jsonify(SubCategoryService.get_by_id(id).to_dict()), 200 + except Exception as e: + return jsonify({'error': str(e)}), 404 + +@bp.route('/subcategories', methods=['POST']) +def create_subcategory(): + # Get current user + current_user = get_current_user_from_token() + + if not current_user: + return jsonify({'error': 'Authentication required'}), 401 + + if current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + + try: + data = request.form.to_dict() + + # ADD CREATED_BY + data['created_by'] = current_user.id + + image = request.files.get('image') + + new_sub = SubCategoryService.create(data, image) + + print(f"✓ SubCategory created by: {current_user.username}") + + return jsonify(new_sub.to_dict()), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +@bp.route('/subcategories/', methods=['PUT']) +def update_subcategory(id): + try: + current_user = get_current_user_from_token() + if not current_user or current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + data = request.form.to_dict() + image = request.files.get('image') if 'image' in request.files else None + from app.services.services import SubCategoryService + updated = SubCategoryService.update(id, data, image) + return jsonify(updated.to_dict()), 200 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/subcategories/', methods=['DELETE']) +def delete_subcategory(id): + try: + current_user = get_current_user_from_token() + if not current_user or current_user.roles not in ['Admin', 'Management', 'SuperAdmin']: + return jsonify({'error': 'Permission denied'}), 403 + from app.services.services import SubCategoryService + SubCategoryService.delete(id) + return jsonify({'message': 'SubCategory deleted'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/uploads/subcategory_images/') +def serve_subcategory_image(filename): + return send_from_directory(os.path.join(bp.root_path, '..', 'Uploads', 'subcategory_images'), filename) \ No newline at end of file diff --git a/Machine-Backend/app/services/__pycache__/__init__.cpython-314.pyc b/Machine-Backend/app/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..eb2252d Binary files /dev/null and b/Machine-Backend/app/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/Machine-Backend/app/services/__pycache__/services.cpython-313.pyc b/Machine-Backend/app/services/__pycache__/services.cpython-313.pyc index e7ccb53..7ae7e94 100644 Binary files a/Machine-Backend/app/services/__pycache__/services.cpython-313.pyc and b/Machine-Backend/app/services/__pycache__/services.cpython-313.pyc differ diff --git a/Machine-Backend/app/services/__pycache__/services.cpython-314.pyc b/Machine-Backend/app/services/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..c97d126 Binary files /dev/null and b/Machine-Backend/app/services/__pycache__/services.cpython-314.pyc differ diff --git a/Machine-Backend/app/services/services.py b/Machine-Backend/app/services/services.py index 5bd6dee..ae96aac 100644 --- a/Machine-Backend/app/services/services.py +++ b/Machine-Backend/app/services/services.py @@ -11,7 +11,7 @@ from typing import Optional, Dict, Any import smtplib from email.mime.text import MIMEText from app import db -from app.models.models import Machine, User, Product, VendingSlot, Transaction, Role +from app.models.models import Machine, User, Product, VendingSlot, Transaction, Role, Branch, Brand, Category, SubCategory from flask import current_app import secrets from dotenv import load_dotenv @@ -299,19 +299,19 @@ class MachineService: machine_model=data['machine_model'], machine_type=data['machine_type'], client_id=client_id, - client_name=client_name, # Set client_name from user record + client_name=client_name, branch_id=branch_id, branch_name=data['branch_name'], operation_status=data['operation_status'], connection_status=data['connection_status'], created_on=created_on, - password=password + password=password, + created_by=data.get('created_by') # NEW: Add created_by ) db.session.add(new_machine) db.session.commit() return new_machine - @staticmethod def update_machine(id, data): machine = Machine.query.get_or_404(id) @@ -382,6 +382,98 @@ class MachineService: db.session.commit() return {'success': True, 'message': 'Vending state updated', 'lastUpdated': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} + @staticmethod + def get_machine_refillers(machine_id): + """ + Get all Refillers assigned to a Machine + + Args: + machine_id: machine_id string + + Returns: + List of User objects (Refillers) + """ + from app.models.models import RefillerMachine, User + + assignments = RefillerMachine.query.filter_by(machine_id=machine_id).all() + + refillers = [] + for assignment in assignments: + refiller = User.query.get(assignment.refiller_id) + if refiller and refiller.roles == 'Refiller': + refillers.append(refiller) + + return refillers + + @staticmethod + def assign_refillers_to_machine(machine_id, refiller_ids, assigned_by_id): + """ + Assign multiple Refillers to a Machine + + Args: + machine_id: machine_id string + refiller_ids: List of Refiller user IDs + assigned_by_id: ID of the user making the assignment + + Returns: + List of created assignments + """ + from app.models.models import RefillerMachine, User, Machine + + print(f"\n{'='*60}") + print(f"ASSIGNING REFILLERS TO MACHINE") + print(f"{'='*60}") + + # Validate machine exists + machine = Machine.query.filter_by(machine_id=machine_id).first() + if not machine: + raise ValueError(f"Machine {machine_id} not found") + + print(f"Machine: {machine_id} ({machine.machine_model})") + print(f"Refillers to assign: {len(refiller_ids)}") + + assignments = [] + + for refiller_id in refiller_ids: + # Validate refiller + refiller = User.query.get(refiller_id) + if not refiller: + print(f" ✗ Refiller {refiller_id} not found - skipping") + continue + if refiller.roles != 'Refiller': + print(f" ✗ User {refiller_id} is not a Refiller - skipping") + continue + + # Check if already assigned + existing = RefillerMachine.query.filter_by( + refiller_id=refiller_id, + machine_id=machine_id + ).first() + + if existing: + print(f" ✓ Refiller {refiller.username} already assigned - skipping") + assignments.append(existing) + continue + + # Create assignment + assignment = RefillerMachine( + refiller_id=refiller_id, + machine_id=machine_id, + assigned_by=assigned_by_id + ) + + db.session.add(assignment) + assignments.append(assignment) + print(f" ✓ Assigned refiller {refiller.username}") + + db.session.commit() + + print(f"\n✓ Successfully assigned {len(assignments)} refillers") + print(f"{'='*60}\n") + + return assignments + + # User Service (MODIFIED FOR BREVO SMTP WITH ENV VARIABLES) # Updated UserService in services.py class UserService: @@ -566,7 +658,7 @@ System Administration Team # Generate user ID only user_id = User.generate_user_id(data['username'], data['email']) - password = data['password'] # Use provided password, not auto-generated + password = data['password'] print(f"Generated User ID: {user_id}") print(f"Password provided by admin") @@ -576,6 +668,17 @@ System Administration Team logo_path = UserService.save_file(logo_file, 'company_logos') if logo_file else None documents = UserService.save_multiple_documents(document_files, documents_metadata) if document_files else [] + # ⭐ NEW: Validate assigned_to if provided + assigned_to = data.get('assigned_to') + if assigned_to: + # Validate that assigned_to user exists and is a Client + client = User.query.get(assigned_to) + if not client: + raise ValueError(f"Client with ID {assigned_to} not found") + if client.roles != 'Client': + raise ValueError(f"User {assigned_to} is not a Client. Can only assign Refillers to Clients.") + print(f"✓ Assigning Refiller to Client: {client.username} (ID: {assigned_to})") + # Create new user new_user = User( user_id=user_id, @@ -586,7 +689,9 @@ System Administration Team user_status=data['user_status'], photo=photo_path, company_logo=logo_path, - documents=json.dumps(documents) + documents=json.dumps(documents), + created_by=data.get('created_by'), + assigned_to=assigned_to # NEW: Set assigned_to ) # Set password (this will hash it automatically) @@ -598,11 +703,10 @@ System Administration Team db.session.commit() print(f"✓ User created successfully with ID: {new_user.id}") + if assigned_to: + print(f"✓ Assigned to Client ID: {assigned_to}") print(f"{'='*60}\n") - # Optional: Send email notification with the password they set - # UserService.send_email_notification(...) - return new_user @staticmethod @@ -634,12 +738,29 @@ System Administration Team user.roles = data.get('roles', user.roles) user.user_status = data.get('user_status', user.user_status) + # ⭐ NEW: Update assigned_to if provided + if 'assigned_to' in data: + new_assigned_to = data['assigned_to'] + + if new_assigned_to: + # Validate that assigned_to user exists and is a Client + client = User.query.get(new_assigned_to) + if not client: + raise ValueError(f"Client with ID {new_assigned_to} not found") + if client.roles != 'Client': + raise ValueError(f"User {new_assigned_to} is not a Client. Can only assign Refillers to Clients.") + print(f"✓ Updating assignment to Client: {client.username} (ID: {new_assigned_to})") + else: + print(f"✓ Removing assignment (set to None)") + + user.assigned_to = new_assigned_to + # Update password if provided (will be hashed automatically) if 'password' in data and data['password']: print(f"Updating password for user") user.set_password(data['password']) - # Handle file updates + # Handle file updates (unchanged) if photo_file: if user.photo: old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], @@ -748,6 +869,173 @@ System Administration Team ] return valid_types.includes(file.type) if hasattr(file, 'type') else False + @staticmethod + def assign_machines_to_refiller(refiller_id, machine_ids, assigned_by_id): + """ + Assign multiple machines to a Refiller + + Args: + refiller_id: ID of the Refiller user + machine_ids: List of machine_id strings + assigned_by_id: ID of the user making the assignment (Admin) + + Returns: + List of created RefillerMachine assignments + """ + from app.models.models import RefillerMachine, User, Machine + + print(f"\n{'='*60}") + print(f"ASSIGNING MACHINES TO REFILLER") + print(f"{'='*60}") + + # Validate refiller exists and is actually a Refiller + refiller = User.query.get(refiller_id) + if not refiller: + raise ValueError(f"Refiller with ID {refiller_id} not found") + if refiller.roles != 'Refiller': + raise ValueError(f"User {refiller_id} is not a Refiller") + + print(f"Refiller: {refiller.username} (ID: {refiller_id})") + print(f"Machines to assign: {len(machine_ids)}") + + # Optional: Validate machines belong to assigned client + if refiller.assigned_to: + client = User.query.get(refiller.assigned_to) + print(f"Assigned to Client: {client.username if client else 'None'}") + + assignments = [] + + for machine_id in machine_ids: + # Validate machine exists + machine = Machine.query.filter_by(machine_id=machine_id).first() + if not machine: + print(f" ✗ Machine {machine_id} not found - skipping") + continue + + # Optional: Check if machine belongs to assigned client + if refiller.assigned_to and machine.client_id != refiller.assigned_to: + print(f" ⚠ Warning: Machine {machine_id} doesn't belong to assigned client") + + # Check if assignment already exists + existing = RefillerMachine.query.filter_by( + refiller_id=refiller_id, + machine_id=machine_id + ).first() + + if existing: + print(f" ✓ Machine {machine_id} already assigned - skipping") + assignments.append(existing) + continue + + # Create new assignment + assignment = RefillerMachine( + refiller_id=refiller_id, + machine_id=machine_id, + assigned_by=assigned_by_id + ) + + db.session.add(assignment) + assignments.append(assignment) + print(f" ✓ Assigned machine {machine_id} ({machine.machine_model})") + + db.session.commit() + + print(f"\n✓ Successfully assigned {len(assignments)} machines") + print(f"{'='*60}\n") + + return assignments + + @staticmethod + def remove_machine_from_refiller(refiller_id, machine_id): + """ + Remove a machine assignment from a Refiller + + Args: + refiller_id: ID of the Refiller user + machine_id: machine_id string to remove + + Returns: + True if removed, False if not found + """ + from app.models.models import RefillerMachine + + assignment = RefillerMachine.query.filter_by( + refiller_id=refiller_id, + machine_id=machine_id + ).first() + + if not assignment: + return False + + db.session.delete(assignment) + db.session.commit() + + print(f"✓ Removed machine {machine_id} from refiller {refiller_id}") + + return True + + @staticmethod + def get_refiller_machines(refiller_id): + """ + Get all machines assigned to a Refiller + + Args: + refiller_id: ID of the Refiller user + + Returns: + List of Machine objects + """ + from app.models.models import RefillerMachine, Machine + + assignments = RefillerMachine.query.filter_by(refiller_id=refiller_id).all() + + machines = [] + for assignment in assignments: + machine = Machine.query.filter_by(machine_id=assignment.machine_id).first() + if machine: + machines.append(machine) + + return machines + + @staticmethod + def update_refiller_machines(refiller_id, machine_ids, assigned_by_id): + """ + Update machine assignments for a Refiller (replaces all existing) + + Args: + refiller_id: ID of the Refiller user + machine_ids: List of machine_id strings (new complete list) + assigned_by_id: ID of the user making the assignment + + Returns: + List of updated assignments + """ + from app.models.models import RefillerMachine + + print(f"\n{'='*60}") + print(f"UPDATING REFILLER MACHINE ASSIGNMENTS") + print(f"{'='*60}") + print(f"Refiller ID: {refiller_id}") + print(f"New machine list: {machine_ids}") + + # Remove all existing assignments + RefillerMachine.query.filter_by(refiller_id=refiller_id).delete() + db.session.commit() + print("✓ Cleared existing assignments") + + # Add new assignments + if machine_ids and len(machine_ids) > 0: + assignments = UserService.assign_machines_to_refiller( + refiller_id, + machine_ids, + assigned_by_id + ) + return assignments + else: + print("✓ No machines to assign") + return [] + + # Product Service (UNCHANGED) class ProductService: @staticmethod @@ -793,12 +1081,30 @@ class ProductService: product_id = ProductService.generate_id("P") created_date = data.get('created_date', datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + # Parse billing and expiration dates + billing_date = None + if data.get('billing_date'): + try: + billing_date = datetime.datetime.strptime(data['billing_date'], "%Y-%m-%d") + except ValueError: + pass + + expiration_date = None + if data.get('expiration_date'): + try: + expiration_date = datetime.datetime.strptime(data['expiration_date'], "%Y-%m-%d") + except ValueError: + pass + new_product = Product( product_id=product_id, product_name=data['product_name'], price=float(data['price']), product_image=image_path, - created_date=created_date + created_date=created_date, + billing_date=billing_date, + expiration_date=expiration_date, + created_by=data.get('created_by') ) db.session.add(new_product) @@ -815,6 +1121,20 @@ class ProductService: product.price = float(data.get('price', product.price)) product.created_date = data.get('created_date', product.created_date) + # Update billing date + if 'billing_date' in data and data['billing_date']: + try: + product.billing_date = datetime.datetime.strptime(data['billing_date'], "%Y-%m-%d") + except ValueError: + pass + + # Update expiration date + if 'expiration_date' in data and data['expiration_date']: + try: + product.expiration_date = datetime.datetime.strptime(data['expiration_date'], "%Y-%m-%d") + except ValueError: + pass + if file: if product.product_image: old_image_path = os.path.join(current_app.config['UPLOAD_FOLDER'], product.product_image.split('/')[-1]) @@ -920,14 +1240,15 @@ class RoleService: new_role = Role( name=name, description=description, - permissions=json.dumps(permissions) + permissions=json.dumps(permissions), + created_by=data.get('created_by') # NEW: Add created_by ) db.session.add(new_role) db.session.commit() return new_role - + @staticmethod def update_role(role_id, data): """Update existing role""" @@ -964,4 +1285,685 @@ class RoleService: db.session.delete(role) db.session.commit() + return True + +class BranchService: + """Service for managing branches""" + + @staticmethod + def validate_branch_data(data): + """Validate branch data""" + import re + + required_fields = ['code', 'name', 'location', 'address', 'contact'] + for field in required_fields: + if field not in data or not data[field]: + raise ValueError(f"Missing required field: {field}") + + # Validate code format (uppercase alphanumeric) + if not re.match(r'^[A-Z0-9]+$', data['code']): + raise ValueError("Branch code must contain only uppercase letters and numbers") + + # Validate contact (10 digits) + if not re.match(r'^\d{10}$', data['contact']): + raise ValueError("Contact must be exactly 10 digits") + + # Validate name length + if len(data['name']) < 3: + raise ValueError("Branch name must be at least 3 characters long") + + # Validate address length + if len(data['address']) < 10: + raise ValueError("Address must be at least 10 characters long") + + @staticmethod + def get_all_branches(): + """Get all branches""" + from app.models.models import Branch + return Branch.query.order_by(Branch.created_at.desc()).all() + + @staticmethod + def get_branch_by_id(branch_id): + """Get branch by ID""" + from app.models.models import Branch + return Branch.query.get_or_404(branch_id) + + @staticmethod + def create_branch(data): + """Create new branch""" + from app.models.models import Branch + from app import db + + # Validate data + BranchService.validate_branch_data(data) + + # Check if code already exists + existing = Branch.query.filter_by(code=data['code']).first() + if existing: + raise ValueError(f"Branch code '{data['code']}' already exists") + + # Generate unique branch ID + branch_id = Branch.generate_branch_id() + + # Create new branch + new_branch = Branch( + branch_id=branch_id, + code=data['code'].upper(), + name=data['name'], + location=data['location'], + address=data['address'], + contact=data['contact'], + created_by=data.get('created_by') # NEW: Add created_by + ) + + db.session.add(new_branch) + db.session.commit() + + print(f"✓ Branch created: {new_branch.name} ({new_branch.branch_id})") + + return new_branch + + @staticmethod + def update_branch(branch_id, data): + """Update existing branch""" + from app.models.models import Branch + from app import db + + branch = Branch.query.get_or_404(branch_id) + + # Validate data + BranchService.validate_branch_data(data) + + # Check if new code conflicts with existing branch + if data['code'] != branch.code: + existing = Branch.query.filter( + Branch.code == data['code'], + Branch.id != branch_id + ).first() + if existing: + raise ValueError(f"Branch code '{data['code']}' already exists") + + # Update fields + branch.code = data['code'].upper() + branch.name = data['name'] + branch.location = data['location'] + branch.address = data['address'] + branch.contact = data['contact'] + branch.updated_at = datetime.datetime.utcnow() + + db.session.commit() + + print(f"✓ Branch updated: {branch.name} ({branch.branch_id})") + + return branch + + @staticmethod + def delete_branch(branch_id): + """Delete branch""" + from app.models.models import Branch + from app import db + + branch = Branch.query.get_or_404(branch_id) + + # TODO: Check if branch is associated with any machines + # This prevents deleting branches that are in use + + branch_name = branch.name + + db.session.delete(branch) + db.session.commit() + + print(f"✓ Branch deleted: {branch_name}") + + return True + + @staticmethod + def search_branches(query): + """Search branches by name, code, or location""" + from app.models.models import Branch + + search_term = f"%{query}%" + + branches = Branch.query.filter( + db.or_( + Branch.name.ilike(search_term), + Branch.code.ilike(search_term), + Branch.location.ilike(search_term), + Branch.branch_id.ilike(search_term) + ) + ).order_by(Branch.created_at.desc()).all() + + return branches + +class BrandService: + """Service for managing brands""" + + @staticmethod + def validate_brand_data(data): + """Validate brand data""" + required_fields = ['name', 'branch_id'] + for field in required_fields: + if field not in data or not data[field]: + raise ValueError(f"Missing required field: {field}") + + # Validate name length + if len(data['name']) < 2: + raise ValueError("Brand name must be at least 2 characters long") + + @staticmethod + def save_image(file): + """Save brand image and return path""" + if not file: + return None + + filename = f"{uuid.uuid4().hex}_{file.filename}" + upload_folder = os.path.join(current_app.config['UPLOAD_FOLDER'], 'brand_images') + + os.makedirs(upload_folder, exist_ok=True) + + file_path = os.path.join(upload_folder, filename) + file.save(file_path) + + return f"/uploads/brand_images/{filename}" + + @staticmethod + def get_all_brands(): + """Get all brands with branch information""" + from app.models.models import Brand + return Brand.query.order_by(Brand.created_at.desc()).all() + + @staticmethod + def get_brand_by_id(brand_id): + """Get brand by ID""" + from app.models.models import Brand + return Brand.query.get_or_404(brand_id) + + @staticmethod + def create_brand(data, image_file=None): + """Create new brand""" + from app.models.models import Brand, Branch + from app import db + + print(f"\n{'='*60}") + print(f"CREATING NEW BRAND") + print(f"{'='*60}") + + # Validate data + BrandService.validate_brand_data(data) + + # Get branch information + branch = Branch.query.filter_by(branch_id=data['branch_id']).first() + if not branch: + raise ValueError(f"Branch not found: {data['branch_id']}") + + # Generate brand ID from name + brand_id = Brand.generate_brand_id(data['name']) + print(f"Generated Brand ID: {brand_id}") + + # Handle image upload + image_path = BrandService.save_image(image_file) if image_file else None + + # Create new brand + new_brand = Brand( + brand_id=brand_id, + name=data['name'], + branch_id=branch.branch_id, + branch_name=branch.name, + image=image_path, + created_by=data.get('created_by') # NEW: Add created_by + ) + + db.session.add(new_brand) + db.session.commit() + + print(f"✓ Brand created: {new_brand.name} ({new_brand.brand_id})") + print(f"✓ Linked to branch: {branch.name}") + print(f"{'='*60}\n") + + return new_brand + + @staticmethod + def update_brand(brand_id, data, image_file=None): + """Update existing brand""" + from app.models.models import Brand, Branch + from app import db + + print(f"\n{'='*60}") + print(f"UPDATING BRAND - ID: {brand_id}") + print(f"{'='*60}") + + brand = Brand.query.get_or_404(brand_id) + + # Validate data + BrandService.validate_brand_data(data) + + # Get branch information if branch changed + if data['branch_id'] != brand.branch_id: + branch = Branch.query.filter_by(branch_id=data['branch_id']).first() + if not branch: + raise ValueError(f"Branch not found: {data['branch_id']}") + brand.branch_id = branch.branch_id + brand.branch_name = branch.name + + # Update name and regenerate brand_id if name changed + if data['name'] != brand.name: + old_brand_id = brand.brand_id + brand.name = data['name'] + brand.brand_id = Brand.generate_brand_id(data['name']) + print(f"Brand ID changed: {old_brand_id} -> {brand.brand_id}") + + # Handle image update + if image_file: + # Delete old image if exists + if brand.image: + old_image_path = os.path.join( + current_app.config['UPLOAD_FOLDER'], + brand.image.replace('/uploads/', '') + ) + if os.path.exists(old_image_path): + os.remove(old_image_path) + + brand.image = BrandService.save_image(image_file) + + brand.updated_at = datetime.datetime.utcnow() + + db.session.commit() + + print(f"✓ Brand updated: {brand.name} ({brand.brand_id})") + print(f"{'='*60}\n") + + return brand + + @staticmethod + def delete_brand(brand_id): + """Delete brand""" + from app.models.models import Brand + from app import db + + brand = Brand.query.get_or_404(brand_id) + + brand_name = brand.name + + # Delete image if exists + if brand.image: + image_path = os.path.join( + current_app.config['UPLOAD_FOLDER'], + brand.image.replace('/uploads/', '') + ) + if os.path.exists(image_path): + os.remove(image_path) + + db.session.delete(brand) + db.session.commit() + + print(f"✓ Brand deleted: {brand_name}") + + return True + + @staticmethod + def search_brands(query): + """Search brands by name or brand_id""" + from app.models.models import Brand + + search_term = f"%{query}%" + + brands = Brand.query.filter( + db.or_( + Brand.name.ilike(search_term), + Brand.brand_id.ilike(search_term), + Brand.branch_name.ilike(search_term) + ) + ).order_by(Brand.created_at.desc()).all() + + return brands + + @staticmethod + def get_brands_by_branch(branch_id): + """Get all brands for a specific branch""" + from app.models.models import Brand + + brands = Brand.query.filter_by(branch_id=branch_id)\ + .order_by(Brand.created_at.desc()).all() + + return brands + +class CategoryService: + """Service for managing categories""" + + @staticmethod + def validate_category_data(data): + """Validate category data""" + required_fields = ['name', 'brand_id', 'branch_id'] + for field in required_fields: + if field not in data or not data[field]: + raise ValueError(f"Missing required field: {field}") + + # Validate name length + if len(data['name']) < 2: + raise ValueError("Category name must be at least 2 characters long") + + @staticmethod + def save_image(file): + """Save category image and return path""" + if not file: + return None + + filename = f"{uuid.uuid4().hex}_{file.filename}" + upload_folder = os.path.join(current_app.config['UPLOAD_FOLDER'], 'category_images') + + os.makedirs(upload_folder, exist_ok=True) + + file_path = os.path.join(upload_folder, filename) + file.save(file_path) + + return f"/uploads/category_images/{filename}" + + @staticmethod + def get_all_categories(): + """Get all categories with related information""" + from app.models.models import Category + return Category.query.order_by(Category.created_at.desc()).all() + + @staticmethod + def get_category_by_id(category_id): + """Get category by ID""" + from app.models.models import Category + return Category.query.get_or_404(category_id) + + @staticmethod + def create_category(data, image_file=None): + """Create new category""" + from app.models.models import Category, Brand, Branch + from app import db + + print(f"\n{'='*60}") + print(f"CREATING NEW CATEGORY") + print(f"{'='*60}") + + # Validate data + CategoryService.validate_category_data(data) + + # Get brand information + brand = Brand.query.filter_by(brand_id=data['brand_id']).first() + if not brand: + raise ValueError(f"Brand not found: {data['brand_id']}") + + # Get branch information + branch = Branch.query.filter_by(branch_id=data['branch_id']).first() + if not branch: + raise ValueError(f"Branch not found: {data['branch_id']}") + + # Generate category ID from name + category_id = Category.generate_category_id(data['name']) + print(f"Generated Category ID: {category_id}") + + # Handle image upload + image_path = CategoryService.save_image(image_file) if image_file else None + + # Create new category + new_category = Category( + category_id=category_id, + name=data['name'], + brand_id=brand.brand_id, + brand_name=brand.name, + branch_id=branch.branch_id, + branch_name=branch.name, + image=image_path, + created_by=data.get('created_by') # NEW: Add created_by + ) + + db.session.add(new_category) + db.session.commit() + + print(f"✓ Category created: {new_category.name} ({new_category.category_id})") + print(f"✓ Linked to brand: {brand.name}") + print(f"✓ Linked to branch: {branch.name}") + print(f"{'='*60}\n") + + return new_category + + @staticmethod + def update_category(category_id, data, image_file=None): + """Update existing category""" + from app.models.models import Category, Brand, Branch + from app import db + + print(f"\n{'='*60}") + print(f"UPDATING CATEGORY - ID: {category_id}") + print(f"{'='*60}") + + category = Category.query.get_or_404(category_id) + + # Validate data + CategoryService.validate_category_data(data) + + # Get brand information if brand changed + if data['brand_id'] != category.brand_id: + brand = Brand.query.filter_by(brand_id=data['brand_id']).first() + if not brand: + raise ValueError(f"Brand not found: {data['brand_id']}") + category.brand_id = brand.brand_id + category.brand_name = brand.name + + # Get branch information if branch changed + if data['branch_id'] != category.branch_id: + branch = Branch.query.filter_by(branch_id=data['branch_id']).first() + if not branch: + raise ValueError(f"Branch not found: {data['branch_id']}") + category.branch_id = branch.branch_id + category.branch_name = branch.name + + # Update name and regenerate category_id if name changed + if data['name'] != category.name: + old_category_id = category.category_id + category.name = data['name'] + category.category_id = Category.generate_category_id(data['name']) + print(f"Category ID changed: {old_category_id} -> {category.category_id}") + + # Handle image update + if image_file: + # Delete old image if exists + if category.image: + old_image_path = os.path.join( + current_app.config['UPLOAD_FOLDER'], + category.image.replace('/uploads/', '') + ) + if os.path.exists(old_image_path): + os.remove(old_image_path) + + category.image = CategoryService.save_image(image_file) + + category.updated_at = datetime.datetime.utcnow() + + db.session.commit() + + print(f"✓ Category updated: {category.name} ({category.category_id})") + print(f"{'='*60}\n") + + return category + + @staticmethod + def delete_category(category_id): + """Delete category""" + from app.models.models import Category + from app import db + + category = Category.query.get_or_404(category_id) + + category_name = category.name + + # Delete image if exists + if category.image: + image_path = os.path.join( + current_app.config['UPLOAD_FOLDER'], + category.image.replace('/uploads/', '') + ) + if os.path.exists(image_path): + os.remove(image_path) + + db.session.delete(category) + db.session.commit() + + print(f"✓ Category deleted: {category_name}") + + return True + + @staticmethod + def search_categories(query): + """Search categories by name or category_id""" + from app.models.models import Category + + search_term = f"%{query}%" + + categories = Category.query.filter( + db.or_( + Category.name.ilike(search_term), + Category.category_id.ilike(search_term), + Category.brand_name.ilike(search_term), + Category.branch_name.ilike(search_term) + ) + ).order_by(Category.created_at.desc()).all() + + return categories + + @staticmethod + def get_categories_by_brand(brand_id): + """Get all categories for a specific brand""" + from app.models.models import Category + + categories = Category.query.filter_by(brand_id=brand_id)\ + .order_by(Category.created_at.desc()).all() + + return categories + + @staticmethod + def get_categories_by_branch(branch_id): + """Get all categories for a specific branch""" + from app.models.models import Category + + categories = Category.query.filter_by(branch_id=branch_id)\ + .order_by(Category.created_at.desc()).all() + + return categories + +class SubCategoryService: + @staticmethod + def validate_data(data): + required = ['name', 'category_id', 'brand_id', 'branch_id'] + for field in required: + if field not in data or not data[field]: + raise ValueError(f"Missing required field: {field}") + if len(data['name']) < 2: + raise ValueError("Name must be at least 2 characters") + + @staticmethod + def save_image(file): + if not file: + return None + filename = f"{uuid.uuid4().hex}_{file.filename}" + folder = os.path.join(current_app.config['UPLOAD_FOLDER'], 'subcategory_images') + os.makedirs(folder, exist_ok=True) + file.save(os.path.join(folder, filename)) + return f"/uploads/subcategory_images/{filename}" + + @staticmethod + def get_all(): + from app.models.models import SubCategory + return SubCategory.query.order_by(SubCategory.created_at.desc()).all() + + @staticmethod + def get_by_id(id): + from app.models.models import SubCategory + return SubCategory.query.get_or_404(id) + + @staticmethod + def create(data, image_file=None): + from app.models.models import SubCategory, Category, Brand, Branch + from app import db + + SubCategoryService.validate_data(data) + + category = Category.query.filter_by(category_id=data['category_id']).first() + if not category: + raise ValueError(f"Category not found") + + brand = Brand.query.filter_by(brand_id=data['brand_id']).first() + if not brand: + raise ValueError(f"Brand not found") + + branch = Branch.query.filter_by(branch_id=data['branch_id']).first() + if not branch: + raise ValueError(f"Branch not found") + + sub_id = SubCategory.generate_sub_category_id(data['name']) + image = SubCategoryService.save_image(image_file) if image_file else None + + new_sub = SubCategory( + sub_category_id=sub_id, + name=data['name'], + image=image, + category_id=category.category_id, + category_name=category.name, + brand_id=brand.brand_id, + brand_name=brand.name, + branch_id=branch.branch_id, + branch_name=branch.name, + created_by=data.get('created_by') # NEW: Add created_by + ) + + db.session.add(new_sub) + db.session.commit() + + print(f"✓ SubCategory created: {new_sub.name} ({new_sub.sub_category_id})") + + return new_sub + + @staticmethod + def update(id, data, image_file=None): + from app.models.models import SubCategory, Category, Brand, Branch + from app import db + subcat = SubCategory.query.get_or_404(id) + SubCategoryService.validate_data(data) + if data['category_id'] != subcat.category_id: + category = Category.query.filter_by(category_id=data['category_id']).first() + if not category: + raise ValueError("Category not found") + subcat.category_id = category.category_id + subcat.category_name = category.name + if data['brand_id'] != subcat.brand_id: + brand = Brand.query.filter_by(brand_id=data['brand_id']).first() + if not brand: + raise ValueError("Brand not found") + subcat.brand_id = brand.brand_id + subcat.brand_name = brand.name + if data['branch_id'] != subcat.branch_id: + branch = Branch.query.filter_by(branch_id=data['branch_id']).first() + if not branch: + raise ValueError("Branch not found") + subcat.branch_id = branch.branch_id + subcat.branch_name = branch.name + if data['name'] != subcat.name: + subcat.name = data['name'] + subcat.sub_category_id = SubCategory.generate_sub_category_id(data['name']) + if image_file: + if subcat.image: + old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], subcat.image.replace('/uploads/', '')) + if os.path.exists(old_path): + os.remove(old_path) + subcat.image = SubCategoryService.save_image(image_file) + subcat.updated_at = datetime.datetime.utcnow() + db.session.commit() + return subcat + + @staticmethod + def delete(id): + from app.models.models import SubCategory + from app import db + subcat = SubCategory.query.get_or_404(id) + if subcat.image: + path = os.path.join(current_app.config['UPLOAD_FOLDER'], subcat.image.replace('/uploads/', '')) + if os.path.exists(path): + os.remove(path) + db.session.delete(subcat) + db.session.commit() return True \ No newline at end of file diff --git a/Machine-Backend/app/uploads/04a721feffc64a119d22391e9ba31514_7up.jpg b/Machine-Backend/app/uploads/04a721feffc64a119d22391e9ba31514_7up.jpg new file mode 100644 index 0000000..dd60a24 Binary files /dev/null and b/Machine-Backend/app/uploads/04a721feffc64a119d22391e9ba31514_7up.jpg differ diff --git a/Machine-Backend/app/uploads/a054b74e7a5c4714bf8199c43c8e58e2_7up.png b/Machine-Backend/app/uploads/a054b74e7a5c4714bf8199c43c8e58e2_7up.png deleted file mode 100644 index b153a93..0000000 Binary files a/Machine-Backend/app/uploads/a054b74e7a5c4714bf8199c43c8e58e2_7up.png and /dev/null differ diff --git a/Machine-Backend/app/uploads/user_documents/f175757d051d4a36bd435aa6e48d6c77_sample.pdf b/Machine-Backend/app/uploads/user_documents/f175757d051d4a36bd435aa6e48d6c77_sample.pdf new file mode 100644 index 0000000..c01805e Binary files /dev/null and b/Machine-Backend/app/uploads/user_documents/f175757d051d4a36bd435aa6e48d6c77_sample.pdf differ diff --git a/Machine-Backend/backup.sql b/Machine-Backend/backup.sql new file mode 100644 index 0000000..e69de29 diff --git a/Machine-Backend/backup_db.py b/Machine-Backend/backup_db.py new file mode 100644 index 0000000..2bb9244 --- /dev/null +++ b/Machine-Backend/backup_db.py @@ -0,0 +1,255 @@ +""" +SQLite Database Backup Script +Usage: python backup_db.py [backup|restore|list] +""" + +import os +import shutil +import sqlite3 +from datetime import datetime +from pathlib import Path + +# Configuration +INSTANCE_DIR = Path(__file__).parent / 'instance' +DB_FILE = INSTANCE_DIR / 'machines.db' +BACKUP_DIR = INSTANCE_DIR / 'backups' + + +def ensure_backup_dir(): + """Create backup directory if it doesn't exist""" + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + + +def backup_database(): + """Create a timestamped backup of the database""" + if not DB_FILE.exists(): + print(f"❌ Database not found: {DB_FILE}") + return False + + ensure_backup_dir() + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_file = BACKUP_DIR / f'machines_backup_{timestamp}.db' + + try: + # Method 1: Simple file copy (faster) + shutil.copy2(DB_FILE, backup_file) + + # Get file size + size = backup_file.stat().st_size + size_mb = size / (1024 * 1024) + + print(f"✅ Backup created successfully!") + print(f" File: {backup_file.name}") + print(f" Size: {size_mb:.2f} MB") + print(f" Location: {backup_file}") + + return True + + except Exception as e: + print(f"❌ Backup failed: {e}") + return False + + +def backup_database_with_integrity(): + """Create a backup using SQLite's built-in backup API (slower but safer)""" + if not DB_FILE.exists(): + print(f"❌ Database not found: {DB_FILE}") + return False + + ensure_backup_dir() + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_file = BACKUP_DIR / f'machines_backup_{timestamp}.db' + + try: + # Connect to source database + source_conn = sqlite3.connect(str(DB_FILE)) + + # Connect to backup database + backup_conn = sqlite3.connect(str(backup_file)) + + # Perform backup + with backup_conn: + source_conn.backup(backup_conn) + + source_conn.close() + backup_conn.close() + + # Get file size + size = backup_file.stat().st_size + size_mb = size / (1024 * 1024) + + print(f"✅ Backup created successfully (with integrity check)!") + print(f" File: {backup_file.name}") + print(f" Size: {size_mb:.2f} MB") + print(f" Location: {backup_file}") + + return True + + except Exception as e: + print(f"❌ Backup failed: {e}") + if backup_file.exists(): + backup_file.unlink() # Delete partial backup + return False + + +def list_backups(): + """List all available backups""" + ensure_backup_dir() + + backups = sorted(BACKUP_DIR.glob('machines_backup_*.db'), reverse=True) + + if not backups: + print("📂 No backups found") + return + + print(f"\n📂 Available Backups ({len(backups)} total):") + print("=" * 80) + + for i, backup in enumerate(backups, 1): + size = backup.stat().st_size / (1024 * 1024) + mtime = datetime.fromtimestamp(backup.stat().st_mtime) + + # Parse timestamp from filename + try: + parts = backup.stem.split('_') + date_str = parts[-2] + time_str = parts[-1] + backup_date = datetime.strptime(f"{date_str}_{time_str}", "%Y%m%d_%H%M%S") + date_display = backup_date.strftime("%Y-%m-%d %H:%M:%S") + except: + date_display = mtime.strftime("%Y-%m-%d %H:%M:%S") + + print(f"{i:2d}. {backup.name}") + print(f" Date: {date_display}") + print(f" Size: {size:.2f} MB") + print() + + +def restore_database(backup_name=None): + """Restore database from a backup""" + ensure_backup_dir() + + backups = sorted(BACKUP_DIR.glob('machines_backup_*.db'), reverse=True) + + if not backups: + print("❌ No backups found") + return False + + # If no backup specified, show list and ask + if not backup_name: + list_backups() + print("=" * 80) + choice = input("Enter backup number to restore (or 'q' to quit): ").strip() + + if choice.lower() == 'q': + print("❌ Restore cancelled") + return False + + try: + index = int(choice) - 1 + if index < 0 or index >= len(backups): + print("❌ Invalid backup number") + return False + backup_file = backups[index] + except ValueError: + print("❌ Invalid input") + return False + else: + # Find backup by name + backup_file = BACKUP_DIR / backup_name + if not backup_file.exists(): + print(f"❌ Backup not found: {backup_name}") + return False + + # Confirm restore + print(f"\n⚠️ WARNING: This will replace your current database!") + print(f" Current: {DB_FILE}") + print(f" Backup: {backup_file.name}") + confirm = input("\nType 'yes' to confirm restore: ").strip().lower() + + if confirm != 'yes': + print("❌ Restore cancelled") + return False + + try: + # Create a safety backup of current database + if DB_FILE.exists(): + safety_backup = BACKUP_DIR / f'machines_before_restore_{datetime.now().strftime("%Y%m%d_%H%M%S")}.db' + shutil.copy2(DB_FILE, safety_backup) + print(f"✅ Safety backup created: {safety_backup.name}") + + # Restore from backup + shutil.copy2(backup_file, DB_FILE) + + print(f"✅ Database restored successfully!") + print(f" Restored from: {backup_file.name}") + print(f"\n⚠️ Remember to restart your Flask server!") + + return True + + except Exception as e: + print(f"❌ Restore failed: {e}") + return False + + +def cleanup_old_backups(keep_count=10): + """Keep only the most recent N backups""" + ensure_backup_dir() + + backups = sorted(BACKUP_DIR.glob('machines_backup_*.db'), reverse=True) + + if len(backups) <= keep_count: + print(f"✅ No cleanup needed (found {len(backups)} backups, keeping {keep_count})") + return + + to_delete = backups[keep_count:] + + print(f"\n🗑️ Cleanup: Keeping {keep_count} most recent backups") + print(f" Deleting {len(to_delete)} old backups...") + + for backup in to_delete: + try: + backup.unlink() + print(f" ✓ Deleted: {backup.name}") + except Exception as e: + print(f" ✗ Failed to delete {backup.name}: {e}") + + print(f"✅ Cleanup complete!") + + +def main(): + """Main entry point""" + import sys + + if len(sys.argv) < 2: + print("Usage: python backup_db.py [backup|restore|list|cleanup]") + print("\nCommands:") + print(" backup - Create a new backup") + print(" restore - Restore from a backup") + print(" list - List all available backups") + print(" cleanup - Delete old backups (keep 10 most recent)") + return + + command = sys.argv[1].lower() + + if command == 'backup': + backup_database() + elif command == 'backup-safe': + backup_database_with_integrity() + elif command == 'restore': + backup_name = sys.argv[2] if len(sys.argv) > 2 else None + restore_database(backup_name) + elif command == 'list': + list_backups() + elif command == 'cleanup': + keep = int(sys.argv[2]) if len(sys.argv) > 2 else 10 + cleanup_old_backups(keep) + else: + print(f"❌ Unknown command: {command}") + print(" Use: backup, restore, list, or cleanup") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/Machine-Backend/migration_sqlite.py b/Machine-Backend/migration_sqlite.py new file mode 100644 index 0000000..ba3f643 --- /dev/null +++ b/Machine-Backend/migration_sqlite.py @@ -0,0 +1,603 @@ +""" +Complete Migration Script with Integrated Backup & Restore +Option 3: Client + Machine Assignment + +FLOW: +1. Backup current database +2. Run migration +3. Verify migration success +4. On failure: Auto-restore from backup + +Usage: python migrate_with_backup.py + +Author: System +Date: 2025-01-25 +Version: 2.0.0 +""" + +import sys +import os +import shutil +from datetime import datetime +from pathlib import Path + +# Add parent directory to path +current_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.dirname(current_dir) +sys.path.insert(0, parent_dir) + + +class DatabaseBackupManager: + """Handles database backup and restore operations""" + + def __init__(self, db_path): + self.db_path = Path(db_path) + self.backup_dir = self.db_path.parent / 'backups' + self.current_backup = None + + def ensure_backup_dir(self): + """Create backup directory if it doesn't exist""" + self.backup_dir.mkdir(parents=True, exist_ok=True) + + def create_backup(self): + """Create timestamped backup of database""" + if not self.db_path.exists(): + raise FileNotFoundError(f"Database not found: {self.db_path}") + + self.ensure_backup_dir() + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_file = self.backup_dir / f'machines_backup_{timestamp}.db' + + try: + # Create backup + shutil.copy2(self.db_path, backup_file) + self.current_backup = backup_file + + # Get file size + size = backup_file.stat().st_size / (1024 * 1024) + + print(f"✅ Backup created successfully!") + print(f" File: {backup_file.name}") + print(f" Size: {size:.2f} MB") + print(f" Location: {backup_file}") + + return backup_file + + except Exception as e: + raise Exception(f"Backup failed: {e}") + + def restore_backup(self, backup_file=None): + """Restore database from backup""" + if backup_file is None: + backup_file = self.current_backup + + if backup_file is None: + raise ValueError("No backup file specified") + + if not backup_file.exists(): + raise FileNotFoundError(f"Backup file not found: {backup_file}") + + try: + # Create safety backup of current database + if self.db_path.exists(): + safety_backup = self.backup_dir / f'machines_before_restore_{datetime.now().strftime("%Y%m%d_%H%M%S")}.db' + shutil.copy2(self.db_path, safety_backup) + print(f"✅ Safety backup created: {safety_backup.name}") + + # Restore from backup + shutil.copy2(backup_file, self.db_path) + + print(f"✅ Database restored successfully!") + print(f" Restored from: {backup_file.name}") + + return True + + except Exception as e: + raise Exception(f"Restore failed: {e}") + + def list_backups(self): + """List all available backups""" + self.ensure_backup_dir() + backups = sorted(self.backup_dir.glob('machines_backup_*.db'), reverse=True) + return backups + + def cleanup_old_backups(self, keep_count=10): + """Keep only the most recent N backups""" + backups = self.list_backups() + + if len(backups) <= keep_count: + print(f"✅ Cleanup: Keeping all {len(backups)} backups") + return + + to_delete = backups[keep_count:] + + print(f"🗑️ Cleanup: Keeping {keep_count} most recent backups") + print(f" Deleting {len(to_delete)} old backups...") + + for backup in to_delete: + try: + backup.unlink() + print(f" ✓ Deleted: {backup.name}") + except Exception as e: + print(f" ✗ Failed to delete {backup.name}: {e}") + + +class MigrationError(Exception): + """Custom exception for migration errors""" + pass + + +class Option3MigrationWithBackup: + """Complete migration with integrated backup and restore""" + + def __init__(self): + self.app = None + self.inspector = None + self.changes_made = [] + self.backup_manager = None + self.backup_file = None + + def print_header(self, title): + """Print formatted header""" + print("\n" + "=" * 70) + print(f" {title}") + print("=" * 70) + + def print_step(self, step_num, description): + """Print step information""" + print(f"\n[Step {step_num}] {description}") + print("-" * 70) + + def initialize(self): + """Initialize Flask app and database""" + try: + from app import create_app, db + from sqlalchemy import inspect, text + + self.app = create_app() + self.db = db + self.text = text + + # Get database path + with self.app.app_context(): + db_uri = self.app.config['SQLALCHEMY_DATABASE_URI'] + if db_uri.startswith('sqlite:///'): + db_path = db_uri.replace('sqlite:///', '') + self.db_path = Path(db_path) + self.backup_manager = DatabaseBackupManager(self.db_path) + else: + raise ValueError("This script only supports SQLite databases") + + self.inspector = inspect(db.engine) + + return True + + except ImportError as e: + print(f"❌ Error importing modules: {e}") + print("Make sure you're running this from the backend directory") + return False + except Exception as e: + print(f"❌ Initialization failed: {e}") + return False + + def create_backup(self): + """Create database backup""" + self.print_step(1, "Creating Database Backup") + + try: + self.backup_file = self.backup_manager.create_backup() + return True + except Exception as e: + print(f"❌ Backup failed: {e}") + return False + + def check_prerequisites(self): + """Check if all required tables exist""" + self.print_step(2, "Checking Prerequisites") + + with self.app.app_context(): + tables = self.inspector.get_table_names() + + required_tables = ['users', 'machines'] + missing_tables = [t for t in required_tables if t not in tables] + + if missing_tables: + raise MigrationError( + f"Required tables missing: {', '.join(missing_tables)}\n" + "Please ensure your database is properly initialized." + ) + + print("✅ All required tables exist") + print(f"✅ Found tables: {', '.join(tables)}") + + def add_assigned_to_column(self): + """Add assigned_to column to users table if not exists""" + self.print_step(3, "Adding assigned_to Column to Users Table") + + with self.app.app_context(): + columns = [col['name'] for col in self.inspector.get_columns('users')] + + if 'assigned_to' in columns: + print("⚠️ assigned_to column already exists - skipping") + return False + + try: + print("Creating assigned_to column...") + + # Add column + self.db.session.execute(self.text(""" + ALTER TABLE users + ADD COLUMN assigned_to INTEGER NULL + """)) + + # Add foreign key constraint + self.db.session.execute(self.text(""" + ALTER TABLE users + ADD CONSTRAINT fk_users_assigned_to + FOREIGN KEY (assigned_to) + REFERENCES users(id) + ON DELETE SET NULL + """)) + + self.db.session.commit() + + print("✅ assigned_to column created successfully") + print("✅ Foreign key constraint added") + + self.changes_made.append('assigned_to_column') + return True + + except Exception as e: + self.db.session.rollback() + raise MigrationError(f"Failed to add assigned_to column: {str(e)}") + + def create_refiller_machines_table(self): + """Create refiller_machines junction table if not exists""" + self.print_step(4, "Creating refiller_machines Junction Table") + + with self.app.app_context(): + tables = self.inspector.get_table_names() + + if 'refiller_machines' in tables: + print("⚠️ refiller_machines table already exists - skipping") + return False + + try: + print("Creating refiller_machines table...") + + self.db.session.execute(self.text(""" + CREATE TABLE refiller_machines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + refiller_id INTEGER NOT NULL, + machine_id VARCHAR(10) NOT NULL, + assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + assigned_by INTEGER NULL, + + CONSTRAINT fk_refiller_machines_refiller + FOREIGN KEY (refiller_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT fk_refiller_machines_machine + FOREIGN KEY (machine_id) + REFERENCES machines(machine_id) + ON DELETE CASCADE, + + CONSTRAINT fk_refiller_machines_assigner + FOREIGN KEY (assigned_by) + REFERENCES users(id) + ON DELETE SET NULL, + + CONSTRAINT unique_refiller_machine + UNIQUE (refiller_id, machine_id) + ) + """)) + + self.db.session.commit() + + print("✅ refiller_machines table created successfully") + + self.changes_made.append('refiller_machines_table') + return True + + except Exception as e: + self.db.session.rollback() + raise MigrationError(f"Failed to create refiller_machines table: {str(e)}") + + def verify_migration(self): + """Verify all changes were applied correctly""" + self.print_step(5, "Verifying Migration") + + with self.app.app_context(): + # Refresh inspector + from sqlalchemy import inspect + self.inspector = inspect(self.db.engine) + + # Check assigned_to column + user_columns = [col['name'] for col in self.inspector.get_columns('users')] + if 'assigned_to' in user_columns: + print("✅ users.assigned_to column exists") + else: + raise MigrationError("Verification failed: assigned_to column not found") + + # Check refiller_machines table + tables = self.inspector.get_table_names() + if 'refiller_machines' in tables: + print("✅ refiller_machines table exists") + + # Verify columns + rm_columns = [col['name'] for col in self.inspector.get_columns('refiller_machines')] + expected_columns = ['id', 'refiller_id', 'machine_id', 'assigned_at', 'assigned_by'] + + for col in expected_columns: + if col in rm_columns: + print(f" ✅ Column '{col}' exists") + else: + raise MigrationError(f"Verification failed: Column '{col}' not found") + else: + raise MigrationError("Verification failed: refiller_machines table not found") + + def restore_on_failure(self): + """Restore database from backup on migration failure""" + self.print_header("🔄 RESTORING FROM BACKUP") + + try: + if self.backup_file: + self.backup_manager.restore_backup(self.backup_file) + print("✅ Database restored successfully from backup") + print(" Your data is safe!") + else: + print("⚠️ No backup file available for restore") + except Exception as e: + print(f"❌ Restore failed: {e}") + print(" Please manually restore from backup") + + def print_summary(self): + """Print migration summary""" + self.print_header("✅ MIGRATION COMPLETED SUCCESSFULLY") + + if not self.changes_made: + print("\n⚠️ No changes were made - all structures already exist") + else: + print(f"\n✅ Successfully applied {len(self.changes_made)} change(s):") + for i, change in enumerate(self.changes_made, 1): + print(f" {i}. {change}") + + # Show backup information + if self.backup_file: + print(f"\n📦 Backup Information:") + print(f" Location: {self.backup_file}") + print(f" You can restore this backup if needed using:") + print(f" python migrate_with_backup.py restore") + + # Cleanup old backups + print() + self.backup_manager.cleanup_old_backups(keep_count=10) + + print("\n" + "=" * 70) + print("NEXT STEPS") + print("=" * 70) + print("\n1. Update Backend Models (app/models/models.py)") + print("2. Update Backend Services (app/services/services.py)") + print("3. Update Backend Routes (app/routes/routes.py)") + print("4. Update Frontend (user and machine modules)") + print("5. Restart Backend: python app.py") + print("6. Test the implementation") + print("\n" + "=" * 70 + "\n") + + def run_migration(self): + """Run the complete migration process""" + try: + self.print_header("🚀 Option 3 Migration with Backup") + + # Step 1: Create backup + if not self.create_backup(): + print("❌ Cannot proceed without backup") + return False + + # Step 2: Check prerequisites + self.check_prerequisites() + + # Step 3: Add assigned_to column + self.add_assigned_to_column() + + # Step 4: Create refiller_machines table + self.create_refiller_machines_table() + + # Step 5: Verify migration + self.verify_migration() + + # Step 6: Print summary + self.print_summary() + + return True + + except MigrationError as e: + print(f"\n❌ Migration Error: {str(e)}") + self.restore_on_failure() + return False + + except Exception as e: + print(f"\n❌ Unexpected Error: {str(e)}") + import traceback + traceback.print_exc() + self.restore_on_failure() + return False + + +def list_backups(): + """List all available backups""" + print("\n" + "=" * 70) + print(" AVAILABLE BACKUPS") + print("=" * 70) + + # Find database path + try: + from app import create_app + app = create_app() + with app.app_context(): + db_uri = app.config['SQLALCHEMY_DATABASE_URI'] + if db_uri.startswith('sqlite:///'): + db_path = db_uri.replace('sqlite:///', '') + backup_manager = DatabaseBackupManager(Path(db_path)) + + backups = backup_manager.list_backups() + + if not backups: + print("\n📂 No backups found") + return + + print(f"\n📂 Found {len(backups)} backup(s):") + print("=" * 70) + + for i, backup in enumerate(backups, 1): + size = backup.stat().st_size / (1024 * 1024) + mtime = datetime.fromtimestamp(backup.stat().st_mtime) + + print(f"{i:2d}. {backup.name}") + print(f" Date: {mtime.strftime('%Y-%m-%d %H:%M:%S')}") + print(f" Size: {size:.2f} MB") + print() + + print("=" * 70 + "\n") + else: + print("❌ Only SQLite databases are supported") + except Exception as e: + print(f"❌ Error: {e}") + + +def restore_from_backup(): + """Interactive restore from backup""" + print("\n" + "=" * 70) + print(" RESTORE FROM BACKUP") + print("=" * 70) + + try: + from app import create_app + app = create_app() + with app.app_context(): + db_uri = app.config['SQLALCHEMY_DATABASE_URI'] + if db_uri.startswith('sqlite:///'): + db_path = db_uri.replace('sqlite:///', '') + backup_manager = DatabaseBackupManager(Path(db_path)) + + backups = backup_manager.list_backups() + + if not backups: + print("\n❌ No backups found") + return + + # List backups + print(f"\n📂 Available backups:") + print("=" * 70) + + for i, backup in enumerate(backups, 1): + size = backup.stat().st_size / (1024 * 1024) + mtime = datetime.fromtimestamp(backup.stat().st_mtime) + + print(f"{i:2d}. {backup.name}") + print(f" Date: {mtime.strftime('%Y-%m-%d %H:%M:%S')}") + print(f" Size: {size:.2f} MB") + print() + + print("=" * 70) + + # Get user choice + choice = input("\nEnter backup number to restore (or 'q' to quit): ").strip() + + if choice.lower() == 'q': + print("❌ Restore cancelled") + return + + try: + index = int(choice) - 1 + if index < 0 or index >= len(backups): + print("❌ Invalid backup number") + return + + backup_file = backups[index] + + # Confirm + print(f"\n⚠️ WARNING: This will replace your current database!") + print(f" Backup: {backup_file.name}") + confirm = input("\nType 'yes' to confirm restore: ").strip().lower() + + if confirm != 'yes': + print("❌ Restore cancelled") + return + + # Restore + backup_manager.restore_backup(backup_file) + print("\n⚠️ Remember to restart your Flask server!") + + except ValueError: + print("❌ Invalid input") + except Exception as e: + print(f"❌ Restore failed: {e}") + else: + print("❌ Only SQLite databases are supported") + except Exception as e: + print(f"❌ Error: {e}") + + +def main(): + """Main entry point""" + print("\n" + "=" * 70) + print(" VENDING MACHINE MANAGEMENT SYSTEM") + print(" Database Migration with Backup & Restore") + print(" Version: 2.0.0") + print("=" * 70) + + if len(sys.argv) > 1: + command = sys.argv[1].lower() + + if command == 'list': + list_backups() + return + elif command == 'restore': + restore_from_backup() + return + elif command == 'migrate': + pass # Continue to migration + else: + print(f"\n❌ Unknown command: {command}") + print("\nUsage:") + print(" python migrate_with_backup.py # Run migration") + print(" python migrate_with_backup.py migrate # Run migration") + print(" python migrate_with_backup.py list # List backups") + print(" python migrate_with_backup.py restore # Restore from backup") + return + + # Run migration + migration = Option3MigrationWithBackup() + + if not migration.initialize(): + print("\n❌ Failed to initialize. Please check your setup.") + sys.exit(1) + + print("\nThis migration will:") + print(" 1. ✅ Create backup of current database") + print(" 2. ✅ Add users.assigned_to column") + print(" 3. ✅ Create refiller_machines table") + print(" 4. ✅ Verify all changes") + print(" 5. ✅ Auto-restore on failure") + + response = input("\nProceed with migration? (yes/no): ").strip().lower() + + if response != 'yes': + print("\n❌ Migration cancelled") + sys.exit(0) + + success = migration.run_migration() + + if success: + print("\n✅ Migration completed successfully!") + sys.exit(0) + else: + print("\n❌ Migration failed! Database has been restored from backup.") + sys.exit(1) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/Machine-Backend/wait_for_db.py b/Machine-Backend/wait_for_db.py index 28618ea..699c2e8 100644 --- a/Machine-Backend/wait_for_db.py +++ b/Machine-Backend/wait_for_db.py @@ -1,38 +1,123 @@ -import time -import sys -import os -from sqlalchemy import create_engine -from sqlalchemy.exc import OperationalError +""" +Database Migration Script for Categories Table +""" -def wait_for_db(max_retries=30, retry_delay=2): - """Wait for database to be ready""" +from app import db, create_app +from sqlalchemy import text + +def create_categories_table(): + """Create categories table""" - mysql_host = os.getenv('MYSQL_HOST', 'db') - mysql_user = os.getenv('MYSQL_USER', 'vendinguser') - mysql_password = os.getenv('MYSQL_PASSWORD', 'vendingpass') - mysql_db = os.getenv('MYSQL_DATABASE', 'vending') + create_table_sql = """ + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + image VARCHAR(255), + brand_id VARCHAR(50) NOT NULL, + brand_name VARCHAR(100) NOT NULL, + branch_id VARCHAR(50) NOT NULL, + branch_name VARCHAR(100) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (brand_id) REFERENCES brands(brand_id), + FOREIGN KEY (branch_id) REFERENCES branches(branch_id) + ); + """ - db_uri = f'mysql+pymysql://{mysql_user}:{mysql_password}@{mysql_host}:3306/{mysql_db}' + create_index_sqls = [ + "CREATE INDEX IF NOT EXISTS idx_category_id ON categories(category_id);", + "CREATE INDEX IF NOT EXISTS idx_category_brand_id ON categories(brand_id);", + "CREATE INDEX IF NOT EXISTS idx_category_branch_id ON categories(branch_id);", + "CREATE INDEX IF NOT EXISTS idx_category_name ON categories(name);" + ] - print(f"⏳ Waiting for MySQL at {mysql_host}:3306...") + try: + app = create_app() + with app.app_context(): + with db.engine.connect() as connection: + # Create the categories table + connection.execute(text(create_table_sql)) + + # Create each index separately (SQLite limitation) + for sql in create_index_sqls: + connection.execute(text(sql)) + + connection.commit() + + print("✓ Categories table created successfully!") + print("✓ Indexes created successfully!") + + # Optional: add sample categories + add_sample = input("\nAdd sample categories? (y/n): ") + if add_sample.lower() == 'y': + add_sample_categories() + + except Exception as e: + print(f"✗ Error creating table: {e}") + import traceback + traceback.print_exc() + + +def add_sample_categories(): + """Add sample category data""" + from app.models.models import Category, Brand, Branch - for attempt in range(1, max_retries + 1): - try: - engine = create_engine(db_uri) - connection = engine.connect() - connection.close() - print("✓ MySQL is ready!") - return True - except OperationalError as e: - if attempt < max_retries: - print(f"Waiting for MySQL... (attempt {attempt}/{max_retries})") - time.sleep(retry_delay) - else: - print(f"❌ Failed to connect to MySQL after {max_retries} attempts") - print(f"Error: {e}") - sys.exit(1) + brands = Brand.query.all() + branches = Branch.query.all() - return False + if not brands or not branches: + print("✗ Please create brands and branches first!") + return + + sample_categories = [ + {'name': 'Beverages', 'brand_id': brands[0].brand_id if len(brands) > 0 else None}, + {'name': 'Snacks', 'brand_id': brands[1].brand_id if len(brands) > 1 else brands[0].brand_id}, + {'name': 'Chocolates', 'brand_id': brands[1].brand_id if len(brands) > 1 else brands[0].brand_id} + ] + + try: + for cat_data in sample_categories: + if not cat_data['brand_id']: + continue + + brand = Brand.query.filter_by(brand_id=cat_data['brand_id']).first() + if not brand: + continue + + branch = Branch.query.filter_by(branch_id=brand.branch_id).first() + if not branch: + continue + + category_id = Category.generate_category_id(cat_data['name']) + + category = Category( + category_id=category_id, + name=cat_data['name'], + brand_id=brand.brand_id, + brand_name=brand.name, + branch_id=branch.branch_id, + branch_name=branch.name, + image=None + ) + + db.session.add(category) + + db.session.commit() + print(f"✓ Added sample categories successfully!") + + # Display all created categories + categories = Category.query.all() + print("\nCreated categories:") + for cat in categories: + print(f" - {cat.category_id}: {cat.name} @ {cat.brand_name} ({cat.branch_name})") + + except Exception as e: + db.session.rollback() + print(f"✗ Error adding sample data: {e}") + import traceback + traceback.print_exc() + if __name__ == '__main__': - wait_for_db() \ No newline at end of file + create_categories_table() diff --git a/Project/microservice.py b/Project/microservice.py index 31be3a3..074c30d 100644 --- a/Project/microservice.py +++ b/Project/microservice.py @@ -15,8 +15,8 @@ CORS(app, ) # Configuration - point to your main backend -# MAIN_BACKEND_URL = "http://127.0.0.1:5000" -MAIN_BACKEND_URL = "https://iotbackend.rootxwire.com" # Change this to your main backend URL +MAIN_BACKEND_URL = "http://127.0.0.1:5000" +# MAIN_BACKEND_URL = "https://iotbackend.rootxwire.com" # Change this to your main backend URL # Add OPTIONS handler for preflight requests @app.before_request diff --git a/fuse-starter-v20.0.0/src/app/core/services/role-state.service.ts b/fuse-starter-v20.0.0/src/app/core/services/role-state.service.ts new file mode 100644 index 0000000..396aabb --- /dev/null +++ b/fuse-starter-v20.0.0/src/app/core/services/role-state.service.ts @@ -0,0 +1,78 @@ +// app/core/services/role-state.service.ts +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +export interface RoleUpdate { + roleId: number; + roleName: string; + permissions: string[]; + timestamp: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class RoleStateService { + // Track when roles are updated + private _roleUpdated = new BehaviorSubject(null); + public roleUpdated$ = this._roleUpdated.asObservable(); + + // Track when a role is deleted + private _roleDeleted = new BehaviorSubject(null); + public roleDeleted$ = this._roleDeleted.asObservable(); + + // Track when a role is created + private _roleCreated = new BehaviorSubject(null); + public roleCreated$ = this._roleCreated.asObservable(); + + constructor() { + console.log('✓ RoleStateService initialized'); + } + + /** + * Notify that a role's permissions have been updated + */ + notifyRoleUpdate(roleId: number, roleName: string, permissions: string[]): void { + const update: RoleUpdate = { + roleId, + roleName, + permissions, + timestamp: Date.now() + }; + + console.log('📢 Role updated:', update); + this._roleUpdated.next(update); + } + + /** + * Notify that a role has been deleted + */ + notifyRoleDeleted(roleId: number): void { + console.log('📢 Role deleted:', roleId); + this._roleDeleted.next(roleId); + } + + /** + * Notify that a role has been created + */ + notifyRoleCreated(roleId: number, roleName: string, permissions: string[]): void { + const update: RoleUpdate = { + roleId, + roleName, + permissions, + timestamp: Date.now() + }; + + console.log('📢 Role created:', update); + this._roleCreated.next(update); + } + + /** + * Clear all notifications + */ + clear(): void { + this._roleUpdated.next(null); + this._roleDeleted.next(null); + this._roleCreated.next(null); + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/mock-api/common/navigation/data.ts b/fuse-starter-v20.0.0/src/app/mock-api/common/navigation/data.ts index 31a4ccb..7e24dc7 100644 --- a/fuse-starter-v20.0.0/src/app/mock-api/common/navigation/data.ts +++ b/fuse-starter-v20.0.0/src/app/mock-api/common/navigation/data.ts @@ -120,96 +120,8 @@ export const defaultNavigation: FuseNavigationItem[] = [ icon: 'heroicons_outline:user-group', link: '/role-management', }, - { - id: 'dashboards.warehouse', - title: 'Warehouse', - type: 'collapsable', - icon: 'heroicons_outline:truck', - link: '/dashboards/warehouse', - children: [ - { - id: 'dashboards.Warehouse.warehouse-list', - title: 'Warehouse List', - type: 'basic', - link: '/warehouse/warehouse-list', - }, - { - id: 'dashboards.Warehouse.vendors', - title: 'Vendors', - type: 'basic', - link: '/warehouse/vendors', - }, - { - id: 'dashboards.Warehouse.current-stock', - title: 'Current Stock', - type: 'basic', - link: '/warehouse/current-stock', - }, - { - id: 'dashboards.Warehouse.stock-in-transit', - title: 'Stock In Transit', - type: 'basic', - link: '/warehouse/stock-in-transit', - }, - { - id: 'dashboards.Warehouse.returned-stock', - title: 'Returned Stock', - type: 'basic', - link: '/warehouse/returned-stock', - }, - { - id: 'dashboards.Warehouse.scrapped-stock', - title: 'Scrapped Stock', - type: 'basic', - link: '/warehouse/scrapped-stock', - }, - ], - }, - { - id: 'dashboards.w-transactions', - title: 'W. Transactions', - type: 'collapsable', - icon: 'heroicons_outline:newspaper', - link: '/dashboards/w-transactions', - children: [ - { - id: 'dashboards.w-transactions.purchase', - title: 'Purchase', - type: 'basic', - link: '/w-transactions/purchase', - }, - { - id: 'dashboards.w-transactions.transfer-request', - title: 'Transfer Request', - type: 'basic', - link: '/w-transactions/transfer-request', - }, - { - id: 'dashboards.w-transactions.refill-request', - title: 'Refill Request', - type: 'basic', - link: '/w-transactions/refill-request', - }, - { - id: 'dashboards.w-transactions.return-request', - title: 'Return Request', - type: 'basic', - link: '/w-transactions/return-request', - }, - { - id: 'dashboards.w-transactions.maintenance-request', - title: 'Maintenance Request', - type: 'basic', - link: '/w-transactions/maintenance-request', - }, - { - id: 'dashboards.w-transactions.maintenance-history', - title: 'Maintenance History', - type: 'basic', - link: '/w-transactions/maintenance-history', - }, - ], - }, + // Warehouse - Hidden + // W. Transactions - Hidden { id: 'dashboards.advertisements', title: 'Advertisements', @@ -249,33 +161,7 @@ export const defaultNavigation: FuseNavigationItem[] = [ }, ], }, - { - id: 'dashboards.user-management', - title: 'User Management', - type: 'collapsable', - icon: 'heroicons_outline:user-circle', - link: '/dashboards/user-management', - children: [ - { - id: 'dashboards.user-management.client.admin-list', - title: 'Client Admin List', - type: 'basic', - link: '/user-management/client-admin-list', - }, - { - id: 'dashboards.user-management.client-user-list', - title: 'Client User List', - type: 'basic', - link: '/user-management/client-user-list', - }, - { - id: 'dashboards.user-management.validation-group-list', - title: 'Validation Group List', - type: 'basic', - link: '/user-management/validation-group-list', - }, - ], - }, + // User Management - Hidden { id: 'dashboards.api-integration', title: 'API Integration', @@ -308,18 +194,18 @@ export const defaultNavigation: FuseNavigationItem[] = [ type: 'basic', link: '/company/company-admin-list', }, - { - id: 'dashboards.company.company-user-list', - title: 'Company User List', - type: 'basic', - link: '/company/company-user-list', - }, - { - id: 'dashboards.company.company-user-role-list', - title: 'Company User Role List', - type: 'basic', - link: 'company/company-user-role-list', - }, + // { + // id: 'dashboards.company.company-user-list', + // title: 'Company User List', + // type: 'basic', + // link: '/company/company-user-list', + // }, + // { + // id: 'dashboards.company.company-user-role-list', + // title: 'Company User Role List', + // type: 'basic', + // link: 'company/company-user-role-list', + // }, ], }, { @@ -349,27 +235,7 @@ export const defaultNavigation: FuseNavigationItem[] = [ }, ], }, - { - id: 'dashboards.offers-coupons', - title: 'Offers & Coupons', - type: 'collapsable', - icon: 'heroicons_outline:banknotes', - link: '/dashboards/offers-coupons', - children: [ - { - id: 'dashboards.offers-coupons.offers', - title: 'Offers', - type: 'basic', - link: '/offers-coupons/offers', - }, - { - id: 'dashboards.offers-coupons.old-offers', - title: 'Old Offers', - type: 'basic', - link: '/offers-coupons/old-offers', - }, - ], - }, + // Offers & Coupons - Hidden { id: 'dashboards.help-support', title: 'Help & Support', @@ -377,14 +243,7 @@ export const defaultNavigation: FuseNavigationItem[] = [ icon: 'heroicons_outline:information-circle', link: '/help-support', }, - { - id: 'dashboards.support-history', - title: 'Support History', - type: 'basic', - icon: 'heroicons_outline:clock', - link: '/support-history', - }, - + // Support History - Hidden ], }, ]; @@ -395,7 +254,7 @@ export const compactNavigation: FuseNavigationItem[] = [ tooltip: 'Dashboards', type: 'aside', icon: 'heroicons_outline:home', - children: [], // This will be filled from defaultNavigation so we don't have to manage multiple sets of the same navigation + children: [], }, ]; export const futuristicNavigation: FuseNavigationItem[] = [ @@ -403,7 +262,7 @@ export const futuristicNavigation: FuseNavigationItem[] = [ id: 'dashboards', title: 'DASHBOARDS', type: 'group', - children: [], // This will be filled from defaultNavigation so we don't have to manage multiple sets of the same navigation + children: [], }, ]; export const horizontalNavigation: FuseNavigationItem[] = [ @@ -412,6 +271,6 @@ export const horizontalNavigation: FuseNavigationItem[] = [ title: 'Dashboards', type: 'group', icon: 'heroicons_outline:home', - children: [], // This will be filled from defaultNavigation so we don't have to manage multiple sets of the same navigation + children: [], }, ]; \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.html b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.html index 8bd6e11..4172517 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.html +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.html @@ -1 +1,326 @@ -

branch-list works!

+
+ +
+ + @if (isLoading) { +
+ +
+ } + +
Branch Management
+ +
+ + + + + + + +
+
+ + +
+ +
+ @if (branches$ | async; as branches) { + @if (branches.length > 0 || selectedBranch) { +
+ +
+ +
Code
+
Branch Name
+ + + + +
Actions
+
+ + + @if (selectedBranch && !selectedBranch.id) { +
+ + + + +
+
New Branch
+
+ + + + + + + + + + + + + + +
+ +
+
+ + +
+ +
+ } + + + @for (branch of branches; track trackByFn($index, branch)) { +
+ + + + +
+
+
{{ branch.code }}
+
{{ branch.branch_id }}
+
+
+ + +
+
{{ branch.name }}
+
+ + + + + + + + + + + +
+ + + +
+
+ + + @if (selectedBranch?.id === branch.id) { +
+ +
+ } + } +
+ + + } @else { + +
+
+ +
+

No branches found

+

Get started by creating your first branch

+ +
+ } + } + + + +
+
+
+
+ +
+

+ {{ branch.id ? 'Edit Branch' : 'Create New Branch' }} +

+ @if (branch.branch_id) { +

Branch ID: {{ branch.branch_id }}

+ } +
+ + +
+ + + Branch Code + + + Code is required + + + Use uppercase letters and numbers only + + Unique branch identifier + + + + + Branch Name + + + Name is required + + + Minimum 3 characters required + + + + + + Location + + + Location is required + + + + + + Contact Number + + + Contact is required + + + Enter valid 10-digit number + + + + + + Address + + + Address is required + + + Minimum 10 characters required + + + + + @if (branch.id && branch.created_by_username) { + + Created By + + + + } +
+ + + @if (branch.id) { +
+
+
+ Created: + {{ branch.created_at | date:'medium' }} +
+ @if (branch.updated_at && branch.updated_at !== branch.created_at) { +
+ Last Updated: + {{ branch.updated_at | date:'medium' }} +
+ } +
+
+ } +
+ + +
+ +
+ @if (flashMessage) { +
+ @if (flashMessage === 'success') { + + {{ branch.id ? 'Updated' : 'Created' }} successfully + } + @if (flashMessage === 'error') { + + Error occurred + } +
+ } + + +
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.scss b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.scss index e69de29..1d67766 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.scss +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.scss @@ -0,0 +1,336 @@ +/* Branch grid layout - NOW 7 COLUMNS WITH CREATED_BY */ +.branch-grid { + // Branch ID, Code, Name, Location, Contact, Created By, Actions (7 columns) + grid-template-columns: 110px 120px 1fr 150px 130px 150px 120px; + + @media (max-width: 1279px) { // xl breakpoint - hide Created By + grid-template-columns: 110px 120px 1fr 150px 130px 120px; + } + + @media (max-width: 1023px) { // lg breakpoint - hide location and contact + grid-template-columns: 110px 120px 1fr 120px; + } + + @media (max-width: 767px) { // md breakpoint - hide branch ID + grid-template-columns: 120px 1fr 120px; + } + + @media (max-width: 639px) { // sm breakpoint - stack vertically + grid-template-columns: 1fr 80px; + } +} + +/* Created By Column Styling */ +.branch-grid > div:nth-child(6) { + display: flex; + align-items: center; + gap: 6px; +} + +.branch-grid > div:nth-child(6) mat-icon { + color: #6b7280; + font-size: 16px; + width: 16px; + height: 16px; +} + +.branch-grid > div:nth-child(6) span { + font-size: 13px; + color: #374151; +} + +.dark .branch-grid > div:nth-child(6) mat-icon { + color: #9ca3af; +} + +.dark .branch-grid > div:nth-child(6) span { + color: #d1d5db; +} + +/* Material form field customizations */ +.fuse-mat-dense { + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .mat-mdc-form-field-infix { + min-height: 40px; + } +} + +.fuse-mat-rounded { + .mat-mdc-form-field-flex { + border-radius: 20px; + background-color: rgba(0, 0, 0, 0.04); + border: none; + } + + &.mat-focused .mat-mdc-form-field-flex { + background-color: rgba(0, 0, 0, 0.06); + } +} + +/* Icon size utilities */ +.icon-size-5 { + width: 20px; + height: 20px; + font-size: 20px; +} + +.icon-size-16 { + width: 64px; + height: 64px; + font-size: 64px; +} + +/* Minimal button styles */ +mat-icon-button { + width: 36px; + height: 36px; + + mat-icon { + transition: all 0.2s ease; + } + + &:hover mat-icon { + transform: scale(1.1); + } +} + +/* Form field minimal styling */ +mat-form-field { + &.mat-mdc-form-field { + width: 100%; + } + + .mat-mdc-text-field-wrapper { + background-color: #ffffff; + } +} + +/* Branch row hover effect */ +.branch-grid > div:not(.sticky):not(.bg-blue-50) { + transition: background-color 0.15s ease; + cursor: pointer; +} + +/* Action buttons minimal styling */ +.mat-mdc-icon-button { + &.mat-primary { + color: #3b82f6; + } + + &.mat-warn { + color: #ef4444; + } +} + +/* Clean header styling */ +.branch-grid.sticky { + letter-spacing: 0.025em; +} + +/* Loading state */ +.animate-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Empty state styling */ +.empty-state { + min-height: 400px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .branch-grid { + gap: 8px; + padding: 12px 16px; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + mat-form-field { + .mat-mdc-text-field-wrapper { + background-color: rgba(255, 255, 255, 0.05); + } + } + + .fuse-mat-rounded .mat-mdc-form-field-flex { + background-color: rgba(255, 255, 255, 0.06); + } + + .fuse-mat-rounded.mat-focused .mat-mdc-form-field-flex { + background-color: rgba(255, 255, 255, 0.08); + } + + .branch-grid > div:hover { + background-color: rgba(255, 255, 255, 0.05) !important; + } +} + +/* Custom scrollbar - minimal */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #9ca3af; +} + +/* Focus states for accessibility */ +.mat-mdc-form-field.mat-focused .mat-mdc-form-field-flex { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +/* Clean transitions */ +* { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Pagination styling - minimal */ +mat-paginator { + background-color: transparent; +} + +/* Branch ID badge styling */ +.font-mono { + letter-spacing: 0.05em; +} + +/* Clean form appearance */ +.mat-mdc-form-field-appearance-outline { + .mat-mdc-form-field-outline { + color: #e5e7eb; + } + + &.mat-focused .mat-mdc-form-field-outline { + color: #3b82f6; + } +} + +/* Minimal hint text */ +.mat-mdc-form-field-hint { + font-size: 12px; + color: #6b7280; +} + +/* Error state - clean */ +.mat-mdc-form-field-error { + font-size: 12px; +} + +/* Button height consistency */ +.mat-mdc-raised-button, +.mat-mdc-outlined-button { + height: 40px; + line-height: 40px; +} + +/* Clean dividers */ +.border-b { + border-color: #e5e7eb; +} + +.border-t { + border-color: #e5e7eb; +} + +/* Typography - clean hierarchy */ +h2 { + font-weight: 600; + line-height: 1.2; +} + +h3 { + font-weight: 600; + line-height: 1.3; +} + +/* Compact spacing */ +.compact-spacing { + padding: 0.5rem; +} + +/* Minimal shadow on details panel */ +.overflow-hidden { + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); +} + +/* Clean action button group */ +.flex.gap-1 { + button { + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + } +} + +/* Smooth hover transitions */ +.transition-colors { + transition-property: background-color, color; + transition-duration: 200ms; +} + +/* Clean badge for branch ID */ +.text-blue-600 { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Minimal progress bar */ +mat-progress-bar { + height: 2px; +} + +/* Clean tooltip */ +.mat-mdc-tooltip { + font-size: 12px; + padding: 6px 12px; +} + +/* Responsive typography */ +@media (max-width: 640px) { + .text-4xl { + font-size: 1.875rem; + } +} + +/* Clean focus ring */ +button:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +/* Minimal snackbar */ +.snackbar-success { + background-color: #10b981; +} + +.snackbar-error { + background-color: #ef4444; +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.ts index 1f2b6f3..cdd5ed2 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.ts +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch-list.component.ts @@ -1,12 +1,325 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms'; +import { MatTableModule } from '@angular/material/table'; +import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; +import { MatSortModule, MatSort } from '@angular/material/sort'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatDialogModule, MatDialog } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, switchMap, takeUntil } from 'rxjs'; +import { BranchService, Branch } from './branch.service'; @Component({ selector: 'app-branch-list', standalone: true, - imports: [], + imports: [ + CommonModule, + ReactiveFormsModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + MatProgressBarModule, + MatDialogModule, + MatSnackBarModule + ], templateUrl: './branch-list.component.html', styleUrl: './branch-list.component.scss' }) -export class BranchListComponent { +export class BranchListComponent implements OnInit, OnDestroy { + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; -} + // Observables + branches$: Observable; + private branchesSubject = new BehaviorSubject([]); + private destroy$ = new Subject(); + + // Form controls + searchInputControl = new FormControl(''); + selectedBranchForm!: FormGroup; + + // State management + selectedBranch: Branch | null = null; + isLoading = false; + flashMessage: 'success' | 'error' | null = null; + + // Pagination + pagination = { + length: 0, + page: 0, + size: 10 + }; + + constructor( + private fb: FormBuilder, + private branchService: BranchService, + private dialog: MatDialog, + private snackBar: MatSnackBar + ) { + this.branches$ = this.branchesSubject.asObservable(); + this.initializeForm(); + } + + ngOnInit(): void { + this.loadBranches(); + this.setupSearch(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Initialize the branch form + */ + private initializeForm(): void { + this.selectedBranchForm = this.fb.group({ + code: ['', [Validators.required, Validators.pattern(/^[A-Z0-9]+$/)]], + name: ['', [Validators.required, Validators.minLength(3)]], + location: ['', [Validators.required]], + address: ['', [Validators.required, Validators.minLength(10)]], + contact: ['', [Validators.required, Validators.pattern(/^[0-9]{10}$/)]] + }); + } + + /** + * Setup search functionality with debounce + */ + private setupSearch(): void { + this.searchInputControl.valueChanges + .pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntil(this.destroy$) + ) + .subscribe((searchTerm) => { + this.filterBranches(searchTerm || ''); + }); + } + + /** + * Load all branches from the backend + */ + loadBranches(): void { + this.isLoading = true; + + this.branchService.getAllBranches() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (branches) => { + this.branchesSubject.next(branches); + this.pagination.length = branches.length; + this.isLoading = false; + }, + error: (error) => { + console.error('Error loading branches:', error); + this.showSnackBar('Failed to load branches', 'error'); + this.isLoading = false; + } + }); + } + + /** + * Filter branches based on search term + */ + private filterBranches(searchTerm: string): void { + this.branchService.getAllBranches() + .pipe( + map(branches => { + if (!searchTerm) { + return branches; + } + + const term = searchTerm.toLowerCase(); + return branches.filter(branch => + branch.name.toLowerCase().includes(term) || + branch.code.toLowerCase().includes(term) || + branch.location.toLowerCase().includes(term) || + branch.branch_id.toLowerCase().includes(term) + ); + }), + takeUntil(this.destroy$) + ) + .subscribe(filtered => { + this.branchesSubject.next(filtered); + this.pagination.length = filtered.length; + }); + } + + /** + * Create new branch - open form + */ + createBranch(): void { + this.selectedBranch = { + id: 0, + branch_id: '', + code: '', + name: '', + location: '', + address: '', + contact: '', + created_at: '', + updated_at: '' + }; + + this.selectedBranchForm.reset(); + this.flashMessage = null; + } + + /** + * Edit existing branch + */ + editBranch(branch: Branch): void { + this.selectedBranch = { ...branch }; + this.selectedBranchForm.patchValue({ + code: branch.code, + name: branch.name, + location: branch.location, + address: branch.address, + contact: branch.contact + }); + this.flashMessage = null; + } + + /** + * Toggle branch details view + */ + toggleDetails(branchId: number): void { + if (this.selectedBranch?.id === branchId) { + this.closeDetails(); + } else { + const branch = this.branchesSubject.value.find(b => b.id === branchId); + if (branch) { + this.editBranch(branch); + } + } + } + + /** + * Close details panel + */ + closeDetails(): void { + this.selectedBranch = null; + this.selectedBranchForm.reset(); + this.flashMessage = null; + } + + /** + * Update or create branch + */ + updateSelectedBranch(): void { + if (this.selectedBranchForm.invalid) { + this.selectedBranchForm.markAllAsTouched(); + return; + } + + this.isLoading = true; + const formData = this.selectedBranchForm.value; + + const operation = this.selectedBranch?.id + ? this.branchService.updateBranch(this.selectedBranch.id, formData) + : this.branchService.createBranch(formData); + + operation + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (branch) => { + this.flashMessage = 'success'; + this.isLoading = false; + this.showSnackBar( + this.selectedBranch?.id ? 'Branch updated successfully' : 'Branch created successfully', + 'success' + ); + + setTimeout(() => { + this.closeDetails(); + this.loadBranches(); + }, 1500); + }, + error: (error) => { + console.error('Error saving branch:', error); + this.flashMessage = 'error'; + this.isLoading = false; + this.showSnackBar( + error.error?.error || 'Failed to save branch', + 'error' + ); + } + }); + } + + /** + * Delete selected branch + */ + deleteSelectedBranch(): void { + if (!this.selectedBranch?.id) { + return; + } + + if (!confirm(`Are you sure you want to delete branch "${this.selectedBranch.name}"?`)) { + return; + } + + this.deleteBranch(this.selectedBranch); + } + + /** + * Delete branch + */ + deleteBranch(branch: Branch): void { + if (!confirm(`Are you sure you want to delete branch "${branch.name}"?`)) { + return; + } + + this.isLoading = true; + + this.branchService.deleteBranch(branch.id) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.showSnackBar('Branch deleted successfully', 'success'); + this.closeDetails(); + this.loadBranches(); + }, + error: (error) => { + console.error('Error deleting branch:', error); + this.showSnackBar( + error.error?.error || 'Failed to delete branch', + 'error' + ); + this.isLoading = false; + } + }); + } + + /** + * Track by function for ngFor + */ + trackByFn(index: number, item: Branch): number { + return item.id; + } + + /** + * Show snackbar notification + */ + private showSnackBar(message: string, type: 'success' | 'error'): void { + this.snackBar.open(message, 'Close', { + duration: 3000, + horizontalPosition: 'end', + verticalPosition: 'top', + panelClass: type === 'success' ? 'snackbar-success' : 'snackbar-error' + }); + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch.service.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch.service.ts new file mode 100644 index 0000000..6f2426e --- /dev/null +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/branch-list/branch.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { environment } from '../../../../../../environments/environment'; + +export interface Branch { + id: number; + branch_id: string; + code: string; + name: string; + location: string; + address: string; + contact: string; + created_at: string; + updated_at: string; + + // ⭐ NEW: Created by fields (automatic from backend) + created_by?: number; + created_by_username?: string; +} + +export interface BranchCreateRequest { + code: string; + name: string; + location: string; + address: string; + contact: string; + // ⭐ NOTE: created_by is NOT included here + // Backend automatically extracts it from JWT token +} + +export interface BranchUpdateRequest extends BranchCreateRequest {} + +@Injectable({ + providedIn: 'root' +}) +export class BranchService { + private apiUrl = `${environment.apiUrl}/branches`; // Adjust based on your environment setup + + constructor(private http: HttpClient) {} + + /** + * Get authorization headers + */ + private getHeaders(): HttpHeaders { + const token = localStorage.getItem('accessToken'); + return new HttpHeaders({ + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '' + }); + } + + /** + * Get all branches + */ + getAllBranches(): Observable { + return this.http.get(this.apiUrl, { headers: this.getHeaders() }) + .pipe( + catchError(this.handleError) + ); + } + + /** + * Get single branch by ID + */ + getBranch(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`, { headers: this.getHeaders() }) + .pipe( + catchError(this.handleError) + ); + } + + /** + * Create new branch + * ⭐ NOTE: Don't send created_by in request + * Backend automatically extracts it from JWT token + */ + createBranch(data: BranchCreateRequest): Observable { + return this.http.post(this.apiUrl, data, { headers: this.getHeaders() }) + .pipe( + catchError(this.handleError) + ); + } + + /** + * Update existing branch + * ⭐ NOTE: Don't update created_by + * It's set once during creation and shouldn't change + */ + updateBranch(id: number, data: BranchUpdateRequest): Observable { + return this.http.put(`${this.apiUrl}/${id}`, data, { headers: this.getHeaders() }) + .pipe( + catchError(this.handleError) + ); + } + + /** + * Delete branch + */ + deleteBranch(id: number): Observable<{ message: string }> { + return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() }) + .pipe( + catchError(this.handleError) + ); + } + + /** + * Search branches by query + */ + searchBranches(query: string): Observable { + return this.http.get(`${this.apiUrl}/search?q=${encodeURIComponent(query)}`, + { headers: this.getHeaders() }) + .pipe( + catchError(this.handleError) + ); + } + + /** + * Handle HTTP errors + */ + private handleError(error: any): Observable { + console.error('API Error:', error); + + let errorMessage = 'An error occurred'; + + if (error.error instanceof ErrorEvent) { + // Client-side error + errorMessage = error.error.message; + } else { + // Server-side error + errorMessage = error.error?.error || error.message || 'Server error'; + } + + return throwError(() => ({ + error: errorMessage, + status: error.status + })); + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.html b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.html index a1e43bc..ffd6e8c 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.html +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.html @@ -1 +1,335 @@ -

client-list works!

+ +
+ +
+ + @if (isLoading) { +
+ +
+ } + + +
+
Client Management
+ + {{ pagination.length }} Clients + +
+ + +
+ + + + + + + + +
+
+ + +
+ +
+ @if (clients$ | async; as clients) { + @if (clients.length > 0 || selectedClient) { +
+ +
+ +
Client Name
+ + + + + + +
+ + + @for (client of clients; track trackByFn($index, client)) { +
+ + + + +
+
+ @if (client.photo) { + + } @else { + + {{ client.username.charAt(0) }} + + } +
+
+
{{ client.username }}
+ @if (client.user_id) { +
{{ client.user_id }}
+ } +
+
+ + + + + + + + + + + + + + + + + + +
+ + + @if (selectedClient?.id === client.id) { +
+ +
+ } + } +
+ + + } @else { +
+ No clients found! +
+ } + } + + + +
+
+
+ +
+
+ +
+ @if (client.photo) { + + } @else { + + {{ client.username.charAt(0) }} + + } +
+ + +
+

{{ client.username }}

+

{{ client.user_id }}

+

{{ client.email }}

+

{{ client.contact }}

+
+
+ + + @if (client.company_logo) { +
+ Company Logo +
+ Company logo +
+
+ } +
+ + +
+
+

+ + Machines ({{ client.machines_count }}) +

+
+ + @if (client.machines && client.machines.length > 0) { +
+ @for (machine of client.machines; track machine.machine_id) { +
+
+
+
{{ machine.machine_id }}
+
{{ machine.machine_model }}
+
{{ machine.machine_type }}
+
+ devices +
+ +
+
+ + {{ machine.branch_name }} +
+ + {{ machine.operation_status }} + +
+ +
+ + + {{ machine.connection_status }} + +
+
+ } +
+ } @else { +
+ devices_other +

No machines assigned yet

+
+ } +
+ + +
+
+

+ + Assigned Refillers ({{ client.refillers_count }}) +

+
+ + @if (client.refillers && client.refillers.length > 0) { +
+ @for (refiller of client.refillers; track refiller.id) { +
+
+
+
{{ refiller.username }}
+
{{ refiller.email }}
+
{{ refiller.contact }}
+
{{ refiller.user_id }}
+
+ person +
+ + + @if (refiller.assigned_machines_count > 0) { +
+
+ Assigned Machines ({{ refiller.assigned_machines_count }}) +
+
+ @for (machine of refiller.assigned_machines; track machine.machine_id) { +
+ {{ machine.machine_id }} + {{ machine.machine_model }} +
+ } +
+
+ } @else { +
+ No machines assigned +
+ } +
+ } +
+ } @else { +
+ group +

No refillers assigned yet

+
+ } +
+ + +
+ +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.scss b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.scss index e69de29..bb9e5a2 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.scss +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.scss @@ -0,0 +1,428 @@ +/* client-list.component.scss */ + +/* Client grid layout - 8 COLUMNS: User ID, Name, Email, Contact, Status, Machine ID, Refillers, Details */ +.client-grid { + // Default: All 8 columns visible on XL screens + grid-template-columns: 140px 2fr 2fr 1.5fr 1fr 1.5fr 1.5fr 80px; + + // Ensure all items are vertically centered + > div { + display: flex; + align-items: center; + } + + @media (max-width: 1279px) { // xl breakpoint - hide Machines and Refillers + grid-template-columns: 140px 2fr 2fr 1.5fr 1fr 80px; + } + + @media (max-width: 1023px) { // lg breakpoint - hide Status and Contact + grid-template-columns: 140px 2fr 2fr 80px; + } + + @media (max-width: 767px) { // md breakpoint - hide User ID + grid-template-columns: 2fr 2fr 80px; + } + + @media (max-width: 639px) { // sm breakpoint - hide Email + grid-template-columns: 1fr 80px; + } +} + +/* Machine and Refiller columns - already centered by parent grid item */ +.client-grid > div:nth-child(6), +.client-grid > div:nth-child(7) { + /* Machine ID in monospace */ + .font-mono { + font-family: 'Courier New', monospace; + } + + /* Refiller names in medium weight */ + .font-medium { + font-weight: 500; + } + + /* For multiple items, use flex-col but still centered */ + > div { + display: flex; + flex-direction: column; + gap: 4px; + } +} + +/* Material form field customizations */ +.fuse-mat-dense { + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .mat-mdc-form-field-infix { + min-height: 40px; + } +} + +.fuse-mat-rounded { + .mat-mdc-form-field-flex { + border-radius: 20px; + background-color: rgba(0, 0, 0, 0.04); + border: none; + } + + &.mat-focused .mat-mdc-form-field-flex { + background-color: rgba(0, 0, 0, 0.06); + } +} + +/* Icon size utilities */ +.icon-size-5 { + width: 20px; + height: 20px; + font-size: 20px; +} + +.icon-size-4 { + width: 16px; + height: 16px; + font-size: 16px; +} + +.icon-size-3 { + width: 14px; + height: 14px; + font-size: 14px; +} + +/* Avatar styles */ +.client-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + font-weight: 600; + font-size: 14px; + text-transform: uppercase; +} + +/* Status badge styles */ +.status-badge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + + &.active, + &.online { + background-color: #dcfce7; + color: #166534; + } + + &.inactive, + &.offline { + background-color: #f3f4f6; + color: #374151; + } + + &.maintenance { + background-color: #fef3c7; + color: #92400e; + } +} + +/* Machine card styles */ +.machine-card { + background: #ffffff; + border-radius: 8px; + padding: 16px; + border: 1px solid #e5e7eb; + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border-color: #3b82f6; + } +} + +/* Refiller card styles */ +.refiller-card { + background: #f0fdf4; + border-radius: 8px; + padding: 16px; + border: 1px solid #bbf7d0; + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border-color: #22c55e; + } +} + +/* Empty state styles */ +.empty-state { + text-align: center; + padding: 32px; + background-color: #f9fafb; + border-radius: 8px; + border: 2px dashed #d1d5db; + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #9ca3af; + margin-bottom: 8px; + } + + p { + color: #6b7280; + font-size: 14px; + margin: 8px 0 0 0; + } +} + +/* Details panel */ +.details-panel { + background: #ffffff; + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +/* Section headers in details */ +.section-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 2px solid #e5e7eb; + + h4 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1f2937; + } + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .machine-card { + background: #1f2937; + border-color: #374151; + color: #f9fafb; + + &:hover { + border-color: #60a5fa; + } + } + + .refiller-card { + background: #064e3b; + border-color: #059669; + color: #f0fdf4; + + &:hover { + border-color: #10b981; + } + } + + .empty-state { + background-color: #1f2937; + border-color: #4b5563; + + p { + color: #9ca3af; + } + } + + .details-panel { + background: #1f2937; + color: #f9fafb; + } + + .section-header { + border-bottom-color: #374151; + + h4 { + color: #f9fafb; + } + } + + .fuse-mat-rounded .mat-mdc-form-field-flex { + background-color: rgba(255, 255, 255, 0.06); + } + + .fuse-mat-rounded.mat-focused .mat-mdc-form-field-flex { + background-color: rgba(255, 255, 255, 0.08); + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .client-grid { + gap: 8px; + padding: 12px 16px; + } + + .machine-card, + .refiller-card { + padding: 12px; + } +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Animations */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.machine-card, +.refiller-card { + animation: slideIn 0.3s ease-out; +} + +/* Badge animations */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.status-badge span { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Hover effects */ +.client-grid > div { + transition: background-color 0.2s ease; +} + +.client-grid > div:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +/* Focus states for accessibility */ +.mat-mdc-button:focus, +.mat-mdc-raised-button:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .status-badge { + border: 2px solid currentColor; + } + + .machine-card, + .refiller-card { + border-width: 2px; + } +} + +/* Print styles */ +@media print { + .mat-mdc-button, + .mat-paginator, + .search-input { + display: none; + } + + .details-panel { + box-shadow: none; + border: 1px solid #000; + } +} + +/* Loading states */ +.loading-overlay { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + z-index: 10; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + + &.loading::after { + opacity: 1; + pointer-events: all; + } +} + +/* Count badge special styles */ +.count-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + + mat-icon { + font-size: 12px; + width: 12px; + height: 12px; + } +} + +/* Grid responsiveness helper */ +@media (max-width: 1279px) { + .grid-cols-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 767px) { + .grid-cols-2, + .grid-cols-3 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.ts index fc3d428..955cc90 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.ts +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.component.ts @@ -1,12 +1,294 @@ -import { Component } from '@angular/core'; +// client-list.component.ts +import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, AfterViewInit } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSort, MatSortModule, Sort } from '@angular/material/sort'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { CommonModule } from '@angular/common'; +import { Observable, Subject, BehaviorSubject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, takeUntil, startWith } from 'rxjs/operators'; +import { ClientListService, ClientDetail, ClientMachine, ClientRefiller } from './client-list.service'; @Component({ selector: 'app-client-list', standalone: true, - imports: [], + imports: [ + CommonModule, + ReactiveFormsModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatProgressBarModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + MatExpansionModule + ], templateUrl: './client-list.component.html', styleUrl: './client-list.component.scss' }) -export class ClientListComponent { +export class ClientListComponent implements OnInit, OnDestroy, AfterViewInit { + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; -} + // Observables + clients$!: Observable; + private _unsubscribeAll: Subject = new Subject(); + + // Data + dataSource = new MatTableDataSource(); + private _clients: BehaviorSubject = new BehaviorSubject([]); + + // UI State + isLoading: boolean = false; + selectedClient: ClientDetail | null = null; + + // Search + searchInputControl: FormControl = new FormControl(''); + + // Pagination + pagination = { + length: 0, + page: 0, + size: 10 + }; + + constructor( + private _clientListService: ClientListService, + private _changeDetectorRef: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.clients$ = this._clients.asObservable(); + + // Setup search + this.searchInputControl.valueChanges + .pipe( + takeUntil(this._unsubscribeAll), + debounceTime(300), + distinctUntilChanged(), + startWith('') + ) + .subscribe(() => { + this.filterClients(); + }); + + // Load clients + this.loadClients(); + } + + ngAfterViewInit(): void { + if (this.paginator) { + this.paginator.page + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe((page: PageEvent) => { + this.pagination.page = page.pageIndex; + this.pagination.size = page.pageSize; + this.filterClients(); + }); + } + + if (this.sort) { + this.sort.sortChange + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe((sort: Sort) => { + this.sortClients(sort); + }); + } + } + + ngOnDestroy(): void { + this._unsubscribeAll.next(null); + this._unsubscribeAll.complete(); + } + + /** + * Load all clients with their details + */ + loadClients(): void { + this.isLoading = true; + + this._clientListService.getClientsWithDetails() + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe( + (clients: ClientDetail[]) => { + console.log('Loaded clients:', clients); + this._clients.next(clients); + this.pagination.length = clients.length; + this.isLoading = false; + this.filterClients(); + this._changeDetectorRef.markForCheck(); + }, + (error) => { + console.error('Error loading clients:', error); + this.isLoading = false; + } + ); + } + + /** + * Filter clients based on search + */ + filterClients(): void { + let filteredClients = this._clients.getValue(); + const searchTerm = this.searchInputControl.value?.toLowerCase() || ''; + + if (searchTerm) { + filteredClients = filteredClients.filter(client => + client.username.toLowerCase().includes(searchTerm) || + client.email.toLowerCase().includes(searchTerm) || + client.contact.includes(searchTerm) || + (client.user_id && client.user_id.toLowerCase().includes(searchTerm)) || + client.refillers.some(r => + r.username.toLowerCase().includes(searchTerm) || + r.email.toLowerCase().includes(searchTerm) + ) + ); + } + + this.pagination.length = filteredClients.length; + + const start = this.pagination.page * this.pagination.size; + const end = start + this.pagination.size; + filteredClients = filteredClients.slice(start, end); + + this.dataSource.data = filteredClients; + this._changeDetectorRef.markForCheck(); + } + + /** + * Sort clients + */ + sortClients(sort: Sort): void { + const clients = this._clients.getValue().slice(); + + if (!sort.active || sort.direction === '') { + this._clients.next(clients); + return; + } + + const sortedClients = clients.sort((a, b) => { + const isAsc = sort.direction === 'asc'; + switch (sort.active) { + case 'user_id': return this.compare(a.user_id, b.user_id, isAsc); + case 'username': return this.compare(a.username, b.username, isAsc); + case 'email': return this.compare(a.email, b.email, isAsc); + case 'contact': return this.compare(a.contact, b.contact, isAsc); + case 'machines_count': return this.compare(a.machines_count, b.machines_count, isAsc); + case 'refillers_count': return this.compare(a.refillers_count, b.refillers_count, isAsc); + default: return 0; + } + }); + + this._clients.next(sortedClients); + this.filterClients(); + } + + private compare(a: string | number | undefined, b: string | number | undefined, isAsc: boolean): number { + return (a! < b! ? -1 : 1) * (isAsc ? 1 : -1); + } + + /** + * Toggle client details + */ + toggleDetails(clientId?: number): void { + if (this.selectedClient && this.selectedClient.id === clientId) { + this.closeDetails(); + return; + } + + const client = this._clients.getValue().find(c => c.id === clientId); + if (client) { + this.selectedClient = { ...client }; + + // Load fresh data for machines and refillers + this.loadClientDetails(client.id); + + this._changeDetectorRef.markForCheck(); + } + } + + /** + * Load detailed information for a client + */ + private loadClientDetails(clientId: number): void { + // Load all machines and filter by client_id + this._clientListService.getClientsWithDetails() + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe( + (clients) => { + const clientData = clients.find(c => c.id === clientId); + if (clientData && this.selectedClient && this.selectedClient.id === clientId) { + // Update machines + this.selectedClient.machines = clientData.machines; + this.selectedClient.machines_count = clientData.machines_count; + + // Update refillers + this.selectedClient.refillers = clientData.refillers; + this.selectedClient.refillers_count = clientData.refillers_count; + + this._changeDetectorRef.markForCheck(); + } + }, + (error) => { + console.error('Error loading client details:', error); + } + ); + } + + /** + * Close details panel + */ + closeDetails(): void { + this.selectedClient = null; + this._changeDetectorRef.markForCheck(); + } + + /** + * Get full file URL + */ + getFullFileUrl(path: string | undefined): string | null { + return this._clientListService.getFullFileUrl(path); + } + + /** + * Track by function for ngFor + */ + trackByFn(index: number, item: any): any { + return item.id || index; + } + + /** + * Get status badge class + */ + getStatusClass(status: string): string { + switch (status.toLowerCase()) { + case 'active': + case 'online': + return 'bg-green-100 text-green-800'; + case 'inactive': + case 'offline': + return 'bg-gray-100 text-gray-800'; + case 'maintenance': + return 'bg-yellow-100 text-yellow-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + } + + /** + * Refresh data + */ + refresh(): void { + this.loadClients(); + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.service.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.service.ts new file mode 100644 index 0000000..7ab3c5c --- /dev/null +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/client-list/client-list.service.ts @@ -0,0 +1,182 @@ +// client-list.service.ts +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { environment } from '@environments/environment'; + +export interface ClientMachine { + machine_id: string; + machine_model: string; + machine_type: string; + branch_name: string; + operation_status: string; + connection_status: string; +} + +export interface ClientRefiller { + id: number; + user_id: string; + username: string; + email: string; + contact: string; + assigned_machines_count: number; + assigned_machines: Array<{ + machine_id: string; + machine_model: string; + branch_name: string; + }>; +} + +export interface ClientDetail { + id: number; + user_id: string; + username: string; + email: string; + contact: string; + photo?: string; + company_logo?: string; + created_at: string; + user_status: string; + + // Machines owned by this client + machines: ClientMachine[]; + machines_count: number; + + // Refillers assigned to this client + refillers: ClientRefiller[]; + refillers_count: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class ClientListService { + private apiUrl = environment.apiUrl; + private backendBaseUrl = environment.apiUrl; + + constructor(private http: HttpClient) {} + + /** + * Get all clients with their machines and refillers + */ + getClientsWithDetails(): Observable { + // First get all users + return this.http.get(`${this.apiUrl}/users`).pipe( + map(users => { + // Filter only clients + const clients = users.filter(u => u.roles === 'Client'); + + // Also get all machines to map to clients + return { clients, users }; + }), + // Now fetch machines + switchMap(({ clients, users }) => { + return this.http.get(`${this.apiUrl}/machines`).pipe( + map(machines => { + // Transform clients with their machines and refillers + return clients.map(client => + this.transformToClientDetail(client, users, machines) + ); + }) + ); + }) + ); + } + + /** + * Get single client with details + */ + getClientById(clientId: number): Observable { + // Fetch client, users, and machines in parallel + return this.http.get(`${this.apiUrl}/users/${clientId}`).pipe( + switchMap(client => { + // Get all users and machines + return this.http.get(`${this.apiUrl}/users`).pipe( + switchMap(users => { + return this.http.get(`${this.apiUrl}/machines`).pipe( + map(machines => this.transformToClientDetail(client, users, machines)) + ); + }) + ); + }) + ); + } + + /** + * Get machines for a specific client + */ + getClientMachines(clientId: number): Observable { + return this.http.get(`${this.apiUrl}/clients/${clientId}/machines`); + } + + /** + * Get refillers assigned to a specific client + */ + getClientRefillers(clientId: number): Observable { + return this.http.get(`${this.apiUrl}/users/${clientId}/assigned-refillers`); + } + + /** + * Transform user data to ClientDetail format + */ + private transformToClientDetail(client: any, allUsers: any[], allMachines: any[]): ClientDetail { + // Get machines owned by this client (client_id matches) + const machines: ClientMachine[] = allMachines + .filter(machine => machine.client_id === client.id) + .map(machine => ({ + machine_id: machine.machine_id, + machine_model: machine.machine_model, + machine_type: machine.machine_type, + branch_name: machine.branch_name, + operation_status: machine.operation_status, + connection_status: machine.connection_status + })); + + // Get refillers assigned to this client + const refillers = allUsers + .filter(u => u.roles === 'Refiller' && u.assigned_to === client.id) + .map(refiller => ({ + id: refiller.id, + user_id: refiller.user_id, + username: refiller.username, + email: refiller.email, + contact: refiller.contact, + assigned_machines_count: refiller.assigned_machines_count || 0, + assigned_machines: refiller.assigned_machines || [] + })); + + return { + id: client.id, + user_id: client.user_id, + username: client.username, + email: client.email, + contact: client.contact, + photo: client.photo, + company_logo: client.company_logo, + created_at: client.created_at, + user_status: client.user_status, + machines: machines, + machines_count: machines.length, + refillers: refillers, + refillers_count: refillers.length + }; + } + + /** + * Get full file URL (same as UserService) + */ + getFullFileUrl(path: string | undefined | null): string | null { + if (!path) return null; + + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + if (path.startsWith('/')) { + return this.backendBaseUrl + path; + } + + return this.backendBaseUrl + '/' + path; + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.html b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.html index f977d37..48f7036 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.html +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.html @@ -1 +1,268 @@ -

company-admin-list works!

+ +
+ +
+ + @if (isLoading) { +
+ +
+ } + + +
Company Admin Management
+ + +
+ + + + + + + + +
+
+ + +
+ +
+ @if (admins$ | async; as admins) { + @if (filteredAdmins.length > 0 || searchInputControl.value) { +
+ +
+ +
Admin Name
+ + + + + +
+ + + @for (admin of filteredAdmins; track trackByFn($index, admin)) { +
+ + + + +
+
+ @if (admin.photo) { + + } @else { + + {{ admin.username.charAt(0) }} + + } +
+
+
{{ admin.username }}
+ @if (admin.created_by_username) { +
+ person_add + by {{ admin.created_by_username }} +
+ } +
+
+ + + + + + + + + + + + + + + +
+ + + @if (selectedAdmin?.id === admin.id) { +
+
+ +
+
+
+ @if (selectedAdmin.photo) { + + } @else { + + {{ selectedAdmin.username.charAt(0) }} + + } +
+
+

{{ selectedAdmin.username }}

+

{{ selectedAdmin.user_id }}

+
+ + {{ selectedAdmin.roles }} + + @if (selectedAdmin.user_status === 'Active') { + + + Active + + } +
+
+
+ @if (selectedAdmin.company_logo) { +
+ Company logo +
+ } +
+ + +
+ +
+

+ contact_mail + Contact Information +

+
+
+ email + {{ selectedAdmin.email }} +
+
+ phone + {{ selectedAdmin.contact }} +
+
+
+ + +
+

+ person_add + Created By +

+
+
+ account_circle + {{ selectedAdmin.created_by_username || 'System' }} +
+
+ calendar_today + {{ selectedAdmin.created_at | date:'short' }} +
+
+
+ + +
+

+ info + Account Status +

+
+
+ verified_user + {{ selectedAdmin.user_status }} +
+ @if (selectedAdmin.updated_at) { +
+ update + Updated: {{ selectedAdmin.updated_at | date:'short' }} +
+ } +
+
+
+ + +
+ +
+
+
+ } + } +
+ + + + + } @else { +
+ No admin users found! +
+ } + } +
+
+
\ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.scss b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.scss index e69de29..0d99bbd 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.scss +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.scss @@ -0,0 +1,237 @@ +/* company-admin-list.component.scss */ + +/* Admin grid layout - 7 COLUMNS: User ID, Name, Email, Contact, Role, Status, Details */ +.admin-grid { + // Default: All 7 columns visible on XL screens + grid-template-columns: 140px 2fr 2fr 1.5fr 1.2fr 1fr 80px; + + // Ensure all items are vertically centered + > div { + display: flex; + align-items: center; + } + + @media (max-width: 1023px) { // lg breakpoint - hide Role and Status + grid-template-columns: 140px 2fr 2fr 1.5fr 80px; + } + + @media (max-width: 767px) { // md breakpoint - hide User ID + grid-template-columns: 2fr 2fr 1.5fr 80px; + } + + @media (max-width: 639px) { // sm breakpoint - hide Email + grid-template-columns: 1fr 80px; + } +} + +/* Hover effect for rows */ +.admin-grid:not(.sticky) { + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.02); + } +} + +/* Dark mode hover */ +@media (prefers-color-scheme: dark) { + .admin-grid:not(.sticky):hover { + background-color: rgba(255, 255, 255, 0.05); + } +} + +/* Avatar styling */ +.admin-grid img { + transition: transform 0.2s ease; +} + +.admin-grid:hover img { + transform: scale(1.05); +} + +/* Status badge animation */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Details panel animation */ +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.admin-grid + .grid { + animation: slideDown 0.3s ease-out; +} + +/* Role badge colors */ +.bg-orange-100 { + background-color: #ffedd5; +} + +.text-orange-800 { + color: #9a3412; +} + +.bg-amber-100 { + background-color: #fef3c7; +} + +.text-amber-800 { + color: #92400e; +} + +.bg-red-100 { + background-color: #fee2e2; +} + +.text-red-800 { + color: #991b1b; +} + +/* Status badge colors */ +.bg-green-100 { + background-color: #dcfce7; +} + +.text-green-800 { + color: #166534; +} + +.bg-green-400 { + background-color: #4ade80; +} + +.bg-gray-100 { + background-color: #f3f4f6; +} + +.text-gray-800 { + color: #1f2937; +} + +.bg-gray-400 { + background-color: #9ca3af; +} + +/* Details panel card styling */ +.bg-white { + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +/* Responsive text sizing */ +@media (max-width: 639px) { + .text-4xl { + font-size: 1.875rem; + line-height: 2.25rem; + } +} + +/* Loading state */ +.pointer-events-none { + pointer-events: none; + opacity: 0.6; +} + +/* Smooth transitions */ +button { + transition: all 0.2s ease; +} + +button:hover { + transform: translateY(-1px); +} + +button:active { + transform: translateY(0); +} + +/* Icon sizing */ +.icon-size-3 { + height: 0.75rem; + width: 0.75rem; + font-size: 0.75rem; +} + +.icon-size-4 { + height: 1rem; + width: 1rem; + font-size: 1rem; +} + +.icon-size-5 { + height: 1.25rem; + width: 1.25rem; + font-size: 1.25rem; +} + +/* Scrollbar styling */ +.sm\:overflow-y-auto::-webkit-scrollbar { + width: 8px; +} + +.sm\:overflow-y-auto::-webkit-scrollbar-track { + background: transparent; +} + +.sm\:overflow-y-auto::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +.sm\:overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* Dark mode scrollbar */ +@media (prefers-color-scheme: dark) { + .sm\:overflow-y-auto::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + } + + .sm\:overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); + } +} + +/* Print styles */ +@media print { + .admin-grid { + grid-template-columns: 100px 1fr 1fr 120px 100px 80px; + page-break-inside: avoid; + } + + button, mat-paginator { + display: none !important; + } +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .admin-grid:hover { + outline: 2px solid currentColor; + } + + .bg-green-100, + .bg-gray-100, + .bg-orange-100, + .bg-amber-100, + .bg-red-100 { + border: 1px solid currentColor; + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.ts index 735a6e0..eec330a 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.ts +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.component.ts @@ -1,12 +1,246 @@ -import { Component } from '@angular/core'; +// company-admin-list.component.ts +import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormsModule, FormControl } from '@angular/forms'; +import { MatTableModule } from '@angular/material/table'; +import { MatPaginatorModule, MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatSortModule, MatSort, Sort } from '@angular/material/sort'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { Observable, Subject, BehaviorSubject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, startWith, takeUntil } from 'rxjs/operators'; +import { CompanyAdminListService, AdminDetail } from './company-admin-list.service'; @Component({ selector: 'app-company-admin-list', standalone: true, - imports: [], + imports: [ + CommonModule, + ReactiveFormsModule, + FormsModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatProgressBarModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule + ], templateUrl: './company-admin-list.component.html', styleUrl: './company-admin-list.component.scss' }) -export class CompanyAdminListComponent { +export class CompanyAdminListComponent implements OnInit, OnDestroy { + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort!: MatSort; -} + // Observables + admins$!: Observable; + private _unsubscribeAll: Subject = new Subject(); + + // Data + private _admins: BehaviorSubject = new BehaviorSubject([]); + filteredAdmins: AdminDetail[] = []; + + // UI State + isLoading: boolean = false; + selectedAdmin: AdminDetail | null = null; + + // Search + searchInputControl: FormControl = new FormControl(''); + + // Pagination + pagination = { + length: 0, + page: 0, + size: 10, + pageSizeOptions: [5, 10, 25, 100] + }; + + constructor( + private _adminService: CompanyAdminListService, + private _changeDetectorRef: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.admins$ = this._admins.asObservable(); + + // Search functionality + this.searchInputControl.valueChanges + .pipe( + takeUntil(this._unsubscribeAll), + debounceTime(300), + distinctUntilChanged(), + startWith('') + ) + .subscribe(() => { + this.filterAdmins(); + }); + + // Load admin users + this.loadAdmins(); + } + + ngOnDestroy(): void { + this._unsubscribeAll.next(null); + this._unsubscribeAll.complete(); + } + + /** + * Load all admin users (Management, SuperAdmin, Admin) + */ + loadAdmins(): void { + this.isLoading = true; + + this._adminService.getAdminsWithDetails() + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe({ + next: (admins) => { + this._admins.next(admins); + this.pagination.length = admins.length; + this.isLoading = false; + this.filterAdmins(); + this._changeDetectorRef.markForCheck(); + + console.log('Loaded admin users:', admins); + }, + error: (error) => { + console.error('Error loading admin users:', error); + this.isLoading = false; + this._changeDetectorRef.markForCheck(); + } + }); + } + + /** + * Filter admins based on search term + */ + filterAdmins(): void { + let admins = this._admins.getValue(); + const searchTerm = this.searchInputControl.value?.toLowerCase() || ''; + + // Apply search filter + if (searchTerm) { + admins = admins.filter(admin => + admin.username.toLowerCase().includes(searchTerm) || + admin.email.toLowerCase().includes(searchTerm) || + admin.contact.includes(searchTerm) || + admin.roles.toLowerCase().includes(searchTerm) || + (admin.user_id && admin.user_id.toLowerCase().includes(searchTerm)) || + (admin.created_by_username && admin.created_by_username.toLowerCase().includes(searchTerm)) + ); + } + + // Update pagination + this.pagination.length = admins.length; + + // Apply pagination + const start = this.pagination.page * this.pagination.size; + const end = start + this.pagination.size; + this.filteredAdmins = admins.slice(start, end); + + this._changeDetectorRef.markForCheck(); + } + + /** + * Sort admins + */ + sortAdmins(sort: Sort): void { + const admins = this._admins.getValue().slice(); + + if (!sort.active || sort.direction === '') { + this._admins.next(admins); + this.filterAdmins(); + return; + } + + const sortedAdmins = admins.sort((a, b) => { + const isAsc = sort.direction === 'asc'; + switch (sort.active) { + case 'user_id': return this.compare(a.user_id, b.user_id, isAsc); + case 'username': return this.compare(a.username, b.username, isAsc); + case 'email': return this.compare(a.email, b.email, isAsc); + case 'contact': return this.compare(a.contact, b.contact, isAsc); + case 'roles': return this.compare(a.roles, b.roles, isAsc); + case 'user_status': return this.compare(a.user_status, b.user_status, isAsc); + default: return 0; + } + }); + + this._admins.next(sortedAdmins); + this.filterAdmins(); + } + + /** + * Compare function for sorting + */ + private compare(a: string | number | undefined, b: string | number | undefined, isAsc: boolean): number { + if (a === undefined || a === null) a = ''; + if (b === undefined || b === null) b = ''; + return (a < b ? -1 : 1) * (isAsc ? 1 : -1); + } + + /** + * Handle pagination change + */ + onPageChange(event: PageEvent): void { + this.pagination.page = event.pageIndex; + this.pagination.size = event.pageSize; + this.filterAdmins(); + } + + /** + * Handle sort change + */ + onSortChange(sort: Sort): void { + this.sortAdmins(sort); + } + + /** + * Toggle admin details + */ + toggleDetails(adminId: number): void { + if (this.selectedAdmin && this.selectedAdmin.id === adminId) { + this.closeDetails(); + return; + } + + const admin = this._admins.getValue().find(a => a.id === adminId); + if (admin) { + this.selectedAdmin = { ...admin }; + this._changeDetectorRef.markForCheck(); + } + } + + /** + * Close details panel + */ + closeDetails(): void { + this.selectedAdmin = null; + this._changeDetectorRef.markForCheck(); + } + + /** + * Refresh admin list + */ + refreshAdmins(): void { + this.loadAdmins(); + } + + /** + * Get full file URL + */ + getFullFileUrl(path: string | undefined): string | null { + return this._adminService.getFullFileUrl(path); + } + + /** + * Track by function for ngFor + */ + trackByFn(index: number, item: AdminDetail): any { + return item.id || index; + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.service.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.service.ts new file mode 100644 index 0000000..d477d55 --- /dev/null +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/company/company-admin-list/company-admin-list.service.ts @@ -0,0 +1,124 @@ +// company-admin-list.service.ts +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { environment } from 'environments/environment'; + +export interface AdminDetail { + id: number; + user_id: string; + username: string; + email: string; + contact: string; + roles: string; + photo?: string; + company_logo?: string; + created_at: string; + updated_at?: string; + user_status: string; + created_by?: number; + created_by_username?: string; + assigned_to?: number; + assigned_to_username?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class CompanyAdminListService { + private apiUrl = environment.apiUrl; + + constructor(private http: HttpClient) {} + + /** + * Get all admin users (Management, SuperAdmin, Admin) + */ + getAdminsWithDetails(): Observable { + return this.http.get(`${this.apiUrl}/users`).pipe( + map(users => { + // Filter only admin roles: Management, SuperAdmin, Admin + const adminUsers = users.filter(u => + u.roles === 'Management' || + u.roles === 'SuperAdmin' || + u.roles === 'Admin' + ); + + // Transform to AdminDetail format + return adminUsers.map(admin => this.transformToAdminDetail(admin, users)); + }) + ); + } + + /** + * Get single admin by ID + */ + getAdminById(adminId: number): Observable { + return this.http.get(`${this.apiUrl}/users/${adminId}`).pipe( + switchMap(admin => { + return this.http.get(`${this.apiUrl}/users`).pipe( + map(users => this.transformToAdminDetail(admin, users)) + ); + }) + ); + } + + /** + * Transform user data to AdminDetail format + */ + private transformToAdminDetail(admin: any, allUsers: any[]): AdminDetail { + // Find who created this admin + let createdByUsername = 'System'; + if (admin.created_by) { + const creator = allUsers.find(u => u.id === admin.created_by); + if (creator) { + createdByUsername = creator.username; + } + } + + // Find who this admin is assigned to (if applicable) + let assignedToUsername = null; + if (admin.assigned_to) { + const assignedUser = allUsers.find(u => u.id === admin.assigned_to); + if (assignedUser) { + assignedToUsername = assignedUser.username; + } + } + + return { + id: admin.id, + user_id: admin.user_id, + username: admin.username, + email: admin.email, + contact: admin.contact, + roles: admin.roles, + photo: admin.photo, + company_logo: admin.company_logo, + created_at: admin.created_at, + updated_at: admin.updated_at, + user_status: admin.user_status, + created_by: admin.created_by, + created_by_username: createdByUsername, + assigned_to: admin.assigned_to, + assigned_to_username: assignedToUsername + }; + } + + /** + * Convert relative file path to full URL + */ + getFullFileUrl(path: string | undefined): string | null { + if (!path) return null; + + // If already a full URL, return as is + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + // Remove leading slash if present + const cleanPath = path.startsWith('/') ? path.substring(1) : path; + + // Construct full URL + return `${this.apiUrl}/${cleanPath}`; + } +} \ No newline at end of file diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/machines/machine-details/machine-details.component.html b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/machines/machine-details/machine-details.component.html index 2a30f6a..049b5bf 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/machines/machine-details/machine-details.component.html +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/machines/machine-details/machine-details.component.html @@ -1,12 +1,15 @@ +
+
-
@@ -17,8 +20,13 @@
+ +
+ Loading... +
+ -
+

Information

@@ -61,7 +69,7 @@
-
+
{{row.name}} @@ -73,19 +81,28 @@
-
+
+ +
{{slot.name}}
- + {{slot.enabled ? 'Enabled' : 'Disabled'}}
+ +
@@ -95,16 +112,27 @@

Assign Product

+
- + + + + +
-

{{slot.product.product_name}}

-
@@ -122,7 +150,7 @@
- + } +

Documents & Files

diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.scss b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.scss index 00d5a70..dc98e46 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.scss +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.scss @@ -1,9 +1,13 @@ -/* User grid layout */ +/* User grid layout - NOW 10 COLUMNS: ID, Username, Email, Contact, Role, Status, Created By, Assigned To, Machines, Details */ .user-grid { - // Default: ID, Username, Email, Contact, Role, Status, Details (7 columns) - grid-template-columns: 60px 2fr 2fr 1.5fr 1fr 1fr 80px; + // Default: All 10 columns visible on XL screens + grid-template-columns: 60px 2fr 2fr 1.5fr 1fr 1fr 150px 150px 120px 80px; - @media (max-width: 1023px) { // lg breakpoint - hide contact and role + @media (max-width: 1279px) { // xl breakpoint - hide Assigned To and Machines + grid-template-columns: 60px 2fr 2fr 1.5fr 1fr 1fr 150px 80px; + } + + @media (max-width: 1023px) { // lg breakpoint - hide Created By, contact and role grid-template-columns: 60px 2fr 2fr 1fr 80px; } @@ -16,6 +20,69 @@ } } +/* Created By Column Styling */ +.user-grid > div:nth-child(7) { + display: flex; + align-items: center; + gap: 6px; +} + +.user-grid > div:nth-child(7) mat-icon { + color: #6b7280; + font-size: 16px; + width: 16px; + height: 16px; +} + +.user-grid > div:nth-child(7) span { + font-size: 13px; + color: #374151; +} + +/* ⭐ NEW: Assigned To Column Styling */ +.user-grid > div:nth-child(8) { + display: flex; + align-items: center; + gap: 6px; +} + +.user-grid > div:nth-child(8) mat-icon { + font-size: 16px; + width: 16px; + height: 16px; +} + +.user-grid > div:nth-child(8) span { + font-size: 13px; +} + +/* ⭐ NEW: Machines Column Styling */ +.user-grid > div:nth-child(9) { + display: flex; + align-items: center; + gap: 4px; +} + +.user-grid > div:nth-child(9) mat-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + +.user-grid > div:nth-child(9) span { + font-size: 12px; +} + +.dark .user-grid > div:nth-child(7) mat-icon, +.dark .user-grid > div:nth-child(8) mat-icon { + color: #9ca3af; +} + +.dark .user-grid > div:nth-child(7) span, +.dark .user-grid > div:nth-child(8) span { + color: #d1d5db; +} + /* Material form field customizations */ .fuse-mat-dense { .mat-mdc-form-field-subscript-wrapper { @@ -52,6 +119,12 @@ font-size: 16px; } +.icon-size-3 { + width: 14px; + height: 14px; + font-size: 14px; +} + /* Custom button styles */ .mat-mdc-button { &.mat-primary { @@ -271,6 +344,7 @@ border: 1px solid currentColor; } } + /* Additional styles for user.component.scss */ /* File upload section styles */ @@ -585,6 +659,7 @@ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } } + .doc-type-badge { &.aadhar { background: #fef2f2; color: #991b1b; } &.pan { background: #eff6ff; color: #1e40af; } diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.ts index ced9684..1d793ca 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.ts +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.component.ts @@ -1,5 +1,5 @@ -// user.component.ts - UPDATED WITH DYNAMIC ROLE FETCHING -import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core'; +// user.component.ts - WITH REAL-TIME ROLE UPDATES +import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, AfterViewInit } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; @@ -17,7 +17,22 @@ import { Observable, Subject, BehaviorSubject, combineLatest } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, startWith, takeUntil } from 'rxjs/operators'; import { User, UserService, UserDocument, DocumentWithMetadata } from '../user/user.service'; import { AuthService } from 'app/core/auth/auth.service'; -import { RoleService, Role } from '../role-management/role-management.service'; // IMPORT ROLE SERVICE +import { RoleService, Role } from '../role-management/role-management.service'; +import { RoleStateService } from 'app/core/services/role-state.service'; + +// ⭐ NEW: Import types for assignment +interface Client { + id: number; + username: string; + email: string; +} + +interface Machine { + machine_id: string; + machine_model: string; + branch_name: string; + operation_status: string; +} @Component({ selector: 'app-user', @@ -41,7 +56,7 @@ import { RoleService, Role } from '../role-management/role-management.service'; MatTooltipModule ] }) -export class UserComponent implements OnInit, OnDestroy { +export class UserComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; @@ -58,13 +73,25 @@ export class UserComponent implements OnInit, OnDestroy { selectedUser: User | null = null; flashMessage: 'success' | 'error' | null = null; - // Role-based properties - UPDATED TO USE DYNAMIC ROLES + // Role-based properties currentUserRole: string | null = null; isClient: boolean = false; isAdmin: boolean = false; availableRoles: { value: string; label: string }[] = []; - allRoles: Role[] = []; // Store all roles from database + allRoles: Role[] = []; rolesLoading: boolean = false; + rolesLoaded: boolean = false; + + // ⭐ NEW: Option 3 - Client Assignment + availableClients: Client[] = []; + clientsLoading: boolean = false; + showClientAssignment: boolean = false; + + // ⭐ NEW: Option 3 - Machine Assignment + availableMachines: Machine[] = []; + selectedMachineIds: string[] = []; + showMachineAssignment: boolean = false; + machinesLoading: boolean = false; // Forms searchInputControl: FormControl = new FormControl(''); @@ -106,16 +133,18 @@ export class UserComponent implements OnInit, OnDestroy { private _formBuilder: FormBuilder, private _changeDetectorRef: ChangeDetectorRef, private _authService: AuthService, - private _roleService: RoleService // INJECT ROLE SERVICE + private _roleService: RoleService, + private _roleStateService: RoleStateService // ⭐ NEW: Inject RoleStateService ) { - // Initialize form + // Initialize form with assigned_to this.selectedUserForm = this._formBuilder.group({ username: ['', [Validators.required]], email: ['', [Validators.required, Validators.email]], contact: ['', [Validators.required, Validators.pattern('^[0-9]{10}$')]], roles: ['', [Validators.required]], user_status: ['Active'], - password: ['', [Validators.required, Validators.minLength(6)]] + password: [''], + assigned_to: [''] }); } @@ -126,10 +155,40 @@ export class UserComponent implements OnInit, OnDestroy { this.isAdmin = this._authService.hasAnyRole(['Management', 'SuperAdmin', 'Admin']); console.log('User Management - Current Role:', this.currentUserRole); + console.log('Is Client:', this.isClient); + console.log('Is Admin:', this.isAdmin); - // Load roles from database + // Load roles from database FIRST this.loadRolesFromDatabase(); + // ⭐ NEW: Subscribe to role changes from Role Management + this.subscribeToRoleChanges(); + + // Load clients for dropdown (only for admins) + if (this.isAdmin) { + this.loadClients(); + } + + // Watch for role and client changes to show/hide machine assignment + combineLatest([ + this.selectedUserForm.get('roles')!.valueChanges.pipe(startWith('')), + this.selectedUserForm.get('assigned_to')!.valueChanges.pipe(startWith('')) + ]).pipe(takeUntil(this._unsubscribeAll)) + .subscribe(([role, clientId]) => { + // Show client assignment dropdown only for Refillers and Admins + this.showClientAssignment = role === 'Refiller' && this.isAdmin; + + // Show machine assignment only when role is Refiller, user is Admin, and client is assigned + this.showMachineAssignment = role === 'Refiller' && this.isAdmin && !!clientId && clientId !== ''; + + if (this.showMachineAssignment && clientId) { + this.loadClientMachines(parseInt(clientId, 10)); + } else { + this.availableMachines = []; + this.selectedMachineIds = []; + } + }); + this.users$ = this._users.asObservable(); this.searchInputControl.valueChanges @@ -143,14 +202,152 @@ export class UserComponent implements OnInit, OnDestroy { this.filterUsers(); }); - this.loadUsers(); + // Load users after a small delay to ensure roles are loaded + setTimeout(() => { + this.loadUsers(); + }, 500); + } + + ngAfterViewInit(): void { + if (this.paginator) { + this.paginator.page + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe((page: PageEvent) => { + this.pagination.page = page.pageIndex; + this.pagination.size = page.pageSize; + this.filterUsers(); + }); + } + + if (this.sort) { + this.sort.sortChange + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe((sort: Sort) => { + this.sortUsers(sort); + }); + } + } + + ngOnDestroy(): void { + this._unsubscribeAll.next(null); + this._unsubscribeAll.complete(); } /** - * UPDATED: Load roles from database instead of hardcoded values + * ⭐ NEW: Subscribe to role changes from Role Management component + */ + private subscribeToRoleChanges(): void { + console.log('📢 Subscribing to role changes...'); + + // Listen for role updates (permission changes) + this._roleStateService.roleUpdated$ + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe(update => { + if (update) { + console.log('📢 ROLE UPDATED notification received:', update); + + // Reload roles to get latest permissions + this.loadRolesFromDatabase(); + + // Update users who have this role + this.updateUsersWithRole(update.roleName); + + // Show notification + this.showRoleUpdateNotification(`Role "${update.roleName}" permissions updated`); + } + }); + + // Listen for role deletions + this._roleStateService.roleDeleted$ + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe(roleId => { + if (roleId) { + console.log('📢 ROLE DELETED notification received:', roleId); + + // Reload roles and users + this.loadRolesFromDatabase(); + this.loadUsers(); + + // Show notification + this.showRoleUpdateNotification('A role has been deleted. User list refreshed.'); + } + }); + + // Listen for new roles + this._roleStateService.roleCreated$ + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe(update => { + if (update) { + console.log('📢 ROLE CREATED notification received:', update); + + // Reload roles + this.loadRolesFromDatabase(); + + // Show notification + this.showRoleUpdateNotification(`New role "${update.roleName}" created`); + } + }); + } + + /** + * ⭐ NEW: Update users in the list who have the changed role + */ + private updateUsersWithRole(roleName: string): void { + const users = this._users.getValue(); + let updatedCount = 0; + + // Mark users with this role as needing refresh + const updatedUsers = users.map(user => { + if (user.roles === roleName) { + updatedCount++; + // Flag for UI update - you might want to show an indicator + return { ...user, _roleUpdated: true }; + } + return user; + }); + + this._users.next(updatedUsers); + this.filterUsers(); + + console.log(`✓ Flagged ${updatedCount} users with role: ${roleName} for update`); + } + + /** + * ⭐ NEW: Show notification about role updates + */ + private showRoleUpdateNotification(message: string): void { + console.log('🔔 Role Update:', message); + + // Option 1: Browser notification (if permission granted) + if ('Notification' in window && Notification.permission === 'granted') { + new Notification('Role Management Update', { + body: message, + icon: '/assets/icons/icon-72x72.png' // Adjust path + }); + } + + // Option 2: Console log (always) + console.log('📬', message); + + // Option 3: Show flash message + this.flashMessage = 'success'; + setTimeout(() => { + this.flashMessage = null; + this._changeDetectorRef.markForCheck(); + }, 3000); + + // Option 4: Add a visual indicator in the UI + // You could add a badge or highlight to the role management button + } + + /** + * Load roles from database */ private loadRolesFromDatabase(): void { this.rolesLoading = true; + this.rolesLoaded = false; + + console.log('Loading roles from database...'); this._roleService.getRoles() .pipe(takeUntil(this._unsubscribeAll)) @@ -163,11 +360,15 @@ export class UserComponent implements OnInit, OnDestroy { this.setAvailableRoles(roles); this.rolesLoading = false; + this.rolesLoaded = true; this._changeDetectorRef.markForCheck(); + + console.log('Available roles for dropdown:', this.availableRoles); }, (error) => { console.error('✗ Error loading roles:', error); this.rolesLoading = false; + this.rolesLoaded = true; // Fallback to empty roles if error this.availableRoles = []; @@ -177,7 +378,7 @@ export class UserComponent implements OnInit, OnDestroy { } /** - * UPDATED: Filter roles based on current user's role (from database) + * Filter roles based on current user's role */ private setAvailableRoles(roles: Role[]): void { let filteredRoles: Role[] = []; @@ -202,6 +403,52 @@ export class UserComponent implements OnInit, OnDestroy { console.log('Available roles for dropdown:', this.availableRoles); } + // ⭐ NEW: Load Clients for assignment dropdown + private loadClients(): void { + this.clientsLoading = true; + this._userService.getClients() + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe({ + next: (clients) => { + this.availableClients = clients; + this.clientsLoading = false; + console.log('Loaded clients:', clients); + }, + error: (err) => { + console.error('Error loading clients:', err); + this.clientsLoading = false; + } + }); + } + + // ⭐ NEW: Load machines for selected client + private loadClientMachines(clientId: number): void { + if (!clientId) { + this.availableMachines = []; + return; + } + + this.machinesLoading = true; + this._userService.getClientMachines(clientId) + .pipe(takeUntil(this._unsubscribeAll)) + .subscribe({ + next: (machines) => { + this.availableMachines = machines; + this.machinesLoading = false; + console.log('Loaded machines for client:', machines); + }, + error: (err) => { + console.error('Error loading client machines:', err); + this.machinesLoading = false; + } + }); + } + + // ⭐ NEW: Remove machine ID from selection + removeMachineId(machineId: string): void { + this.selectedMachineIds = this.selectedMachineIds.filter(id => id !== machineId); + } + /** * Refresh roles from database */ @@ -210,29 +457,38 @@ export class UserComponent implements OnInit, OnDestroy { this.loadRolesFromDatabase(); } - ngOnDestroy(): void { - this._unsubscribeAll.next(null); - this._unsubscribeAll.complete(); + /** + * Check if form is ready to submit + */ + isFormReady(): boolean { + return this.selectedUserForm.valid && !this.isLoading && !this.rolesLoading && this.rolesLoaded; } - ngAfterViewInit(): void { - if (this.paginator) { - this.paginator.page - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe((page: PageEvent) => { - this.pagination.page = page.pageIndex; - this.pagination.size = page.pageSize; - this.filterUsers(); + /** + * Debug form validation + */ + debugFormValidation(): void { + console.log('=== FORM VALIDATION DEBUG ==='); + console.log('Form valid:', this.selectedUserForm.valid); + console.log('Form errors:', this.selectedUserForm.errors); + console.log('Roles loaded:', this.rolesLoaded); + console.log('Roles loading:', this.rolesLoading); + console.log('Available roles:', this.availableRoles); + + Object.keys(this.selectedUserForm.controls).forEach(key => { + const control = this.selectedUserForm.get(key); + if (control?.invalid) { + console.log(`❌ ${key}:`, { + errors: control.errors, + value: control.value, + touched: control.touched, + dirty: control.dirty }); - } - - if (this.sort) { - this.sort.sortChange - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe((sort: Sort) => { - this.sortUsers(sort); - }); - } + } else { + console.log(`✓ ${key}:`, control?.value); + } + }); + console.log('=== END DEBUG ==='); } trackByFn(index: number, item: any): any { @@ -336,14 +592,30 @@ export class UserComponent implements OnInit, OnDestroy { email: user.email, contact: user.contact, roles: user.roles, - user_status: user.user_status + user_status: user.user_status, + assigned_to: user.assigned_to || '' }); this.photoPreview = this._userService.getFullFileUrl(user.photo); this.logoPreview = this._userService.getFullFileUrl(user.company_logo); + // Load assigned machines for Refillers + if (user.roles === 'Refiller') { + this.selectedMachineIds = user.assigned_machines?.map((m: any) => m.machine_id) || []; + + // Load available machines if client is assigned + if (user.assigned_to) { + this.loadClientMachines(user.assigned_to); + } + } else { + this.selectedMachineIds = []; + } + + // For existing users, password is optional this.selectedUserForm.get('password')?.clearValidators(); this.selectedUserForm.get('password')?.updateValueAndValidity(); + + this._changeDetectorRef.markForCheck(); } } @@ -359,15 +631,32 @@ export class UserComponent implements OnInit, OnDestroy { this.photoPreview = null; this.logoPreview = null; this.viewingDocument = null; + this.selectedMachineIds = []; + this.selectedUserForm.reset(); + this._changeDetectorRef.markForCheck(); } createUser(): void { + console.log('Creating new user...'); + console.log('Roles loaded:', this.rolesLoaded); + console.log('Available roles:', this.availableRoles); + if (this.selectedUser) { this.closeDetails(); } - // For clients, auto-set role to Refiller - const defaultRole = this.isClient ? 'Refiller' : ''; + // Determine default role + let defaultRole = ''; + + if (this.isClient) { + // Clients can only create Refiller users + defaultRole = 'Refiller'; + console.log('Client creating user - default role: Refiller'); + } else if (this.availableRoles.length > 0) { + // For admins, use first available role as default + defaultRole = this.availableRoles[0].value; + console.log('Admin creating user - default role:', defaultRole); + } const newUser: User = { username: '', @@ -380,17 +669,27 @@ export class UserComponent implements OnInit, OnDestroy { this.selectedUser = newUser; + // Reset form and set defaults this.selectedUserForm.reset(); this.selectedUserForm.patchValue({ user_status: 'Active', - roles: defaultRole + roles: defaultRole, + assigned_to: '' }); + + this.selectedMachineIds = []; + // For new users, password is required this.selectedUserForm.get('password')?.setValidators([Validators.required, Validators.minLength(6)]); this.selectedUserForm.get('password')?.updateValueAndValidity(); + // Scroll to top window.scrollTo({ top: 0, behavior: 'smooth' }); + this._changeDetectorRef.markForCheck(); + + // Debug form state + this.debugFormValidation(); } onPhotoSelected(event: any): void { @@ -520,14 +819,30 @@ export class UserComponent implements OnInit, OnDestroy { } updateSelectedUser(): void { + // Debug form before submission + this.debugFormValidation(); + if (!this.selectedUserForm.valid) { + console.warn('Form is invalid, cannot submit'); + alert('Please fill in all required fields correctly.'); + return; + } + + if (!this.rolesLoaded) { + console.warn('Roles not loaded yet'); + alert('Please wait for roles to load.'); return; } this.isLoading = true; const formValue = this.selectedUserForm.value; + console.log('Submitting user with role:', formValue.roles); + console.log('Assigned to client:', formValue.assigned_to); + console.log('Selected machines:', this.selectedMachineIds); + if (this.selectedUser?.id) { + // Update existing user this._userService.updateUser( this.selectedUser.id, formValue, @@ -538,18 +853,40 @@ export class UserComponent implements OnInit, OnDestroy { .pipe(takeUntil(this._unsubscribeAll)) .subscribe( () => { - this.loadUsers(); - this.showFlashMessage('success'); - this.closeDetails(); - this.isLoading = false; + console.log('✓ User updated successfully'); + + // Update machine assignments if Refiller + if (formValue.roles === 'Refiller' && this.isAdmin && this.selectedMachineIds.length > 0) { + this._userService.updateRefillerMachines(this.selectedUser!.id!, this.selectedMachineIds) + .subscribe({ + next: (machineResponse) => { + console.log('Machines updated:', machineResponse); + this.loadUsers(); + this.showFlashMessage('success'); + this.closeDetails(); + this.isLoading = false; + }, + error: (err) => { + console.error('Error updating machines:', err); + this.showFlashMessage('error'); + this.isLoading = false; + } + }); + } else { + this.loadUsers(); + this.showFlashMessage('success'); + this.closeDetails(); + this.isLoading = false; + } }, (error) => { - console.error('Error updating user:', error); + console.error('✗ Error updating user:', error); this.showFlashMessage('error'); this.isLoading = false; } ); } else { + // Create new user this._userService.addUser( formValue, this.selectedPhoto || undefined, @@ -558,14 +895,35 @@ export class UserComponent implements OnInit, OnDestroy { ) .pipe(takeUntil(this._unsubscribeAll)) .subscribe( - () => { - this.loadUsers(); - this.showFlashMessage('success'); - this.closeDetails(); - this.isLoading = false; + (created) => { + console.log('✓ User created successfully'); + + // Assign machines if Refiller + if (formValue.roles === 'Refiller' && this.isAdmin && this.selectedMachineIds.length > 0) { + this._userService.assignMachinesToRefiller(created.id!, this.selectedMachineIds) + .subscribe({ + next: (machineResponse) => { + console.log('Machines assigned:', machineResponse); + this.loadUsers(); + this.showFlashMessage('success'); + this.closeDetails(); + this.isLoading = false; + }, + error: (err) => { + console.error('Error assigning machines:', err); + this.showFlashMessage('error'); + this.isLoading = false; + } + }); + } else { + this.loadUsers(); + this.showFlashMessage('success'); + this.closeDetails(); + this.isLoading = false; + } }, (error) => { - console.error('Error creating user:', error); + console.error('✗ Error creating user:', error); this.showFlashMessage('error'); this.isLoading = false; } diff --git a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.service.ts b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.service.ts index 9ffc7e1..63e72b7 100644 --- a/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.service.ts +++ b/fuse-starter-v20.0.0/src/app/modules/admin/dashboard/user/user.service.ts @@ -1,4 +1,4 @@ -// user.service.ts - COMPLETE FIXED VERSION +// user.service.ts - WITH OPTION 3 ASSIGNMENT METHODS import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; @@ -27,6 +27,21 @@ export interface User { created_at?: string; updated_at?: string; machines?: any[]; + + // ⭐ Created by fields (automatic from backend) + created_by?: number; + created_by_username?: string; + + // ⭐ NEW: Option 3 - Assignment fields + assigned_to?: number | null; + assigned_to_username?: string; + assigned_machines?: Array<{ + machine_id: string; + machine_model: string; + branch_name: string; + operation_status: string; + }>; + assigned_machines_count?: number; } export interface DocumentWithMetadata { @@ -35,6 +50,21 @@ export interface DocumentWithMetadata { documentTypeOther?: string; } +// ⭐ NEW: Client interface for assignment dropdown +export interface Client { + id: number; + username: string; + email: string; +} + +// ⭐ NEW: Machine interface for assignment +export interface Machine { + machine_id: string; + machine_model: string; + branch_name: string; + operation_status: string; +} + @Injectable({ providedIn: 'root' }) @@ -70,6 +100,40 @@ export class UserService { return this.http.get(this.BaseUrl); } + // ⭐ NEW: Get Clients for assignment dropdown + getClients(): Observable { + return this.http.get(`${this.backendBaseUrl}/clients`); + } + + // ⭐ NEW: Get machines for a specific client + getClientMachines(clientId: number): Observable { + return this.http.get(`${this.backendBaseUrl}/clients/${clientId}/machines`); + } + + // ⭐ NEW: Assign machines to Refiller (POST - for new user) + assignMachinesToRefiller(refillerId: number, machineIds: string[]): Observable { + return this.http.post(`${this.backendBaseUrl}/refillers/${refillerId}/machines`, { + machine_ids: machineIds + }); + } + + // ⭐ NEW: Update Refiller machines (PUT - for existing user) + updateRefillerMachines(refillerId: number, machineIds: string[]): Observable { + return this.http.put(`${this.backendBaseUrl}/refillers/${refillerId}/machines`, { + machine_ids: machineIds + }); + } + + // ⭐ NEW: Delete machine assignment + deleteRefillerMachine(refillerId: number, machineId: string): Observable { + return this.http.delete(`${this.backendBaseUrl}/refillers/${refillerId}/machines/${machineId}`); + } + + // ⭐ NEW: Get machines assigned to a Refiller + getRefillerMachines(refillerId: number): Observable { + return this.http.get(`${this.backendBaseUrl}/refillers/${refillerId}/machines`); + } + addUser( user: Partial, photoFile?: File, @@ -77,6 +141,10 @@ export class UserService { documentFiles?: DocumentWithMetadata[] ): Observable { const formData = this.createFormData(user, photoFile, logoFile, documentFiles); + + // ⭐ NOTE: Don't add created_by to FormData + // Backend automatically extracts it from JWT token + return this.http.post(this.BaseUrl, formData); } @@ -89,6 +157,10 @@ export class UserService { ): Observable { const url = `${this.BaseUrl}/${id}`; const formData = this.createFormData(user, photoFile, logoFile, documentFiles); + + // ⭐ NOTE: Don't update created_by + // It's set once during creation and shouldn't change + console.log('PUT Request URL:', url); return this.http.put(url, formData); } @@ -123,6 +195,14 @@ export class UserService { if (user.user_status) formData.append('user_status', user.user_status); if (user.password) formData.append('password', user.password); + // ⭐ NEW: Add assigned_to for client assignment + if (user.assigned_to !== undefined) { + formData.append('assigned_to', user.assigned_to ? user.assigned_to.toString() : ''); + } + + // ⭐ NOTE: created_by is NOT added here + // Backend extracts it from JWT token automatically + // Append photo file if (photoFile) { formData.append('photo', photoFile, photoFile.name); diff --git a/machine-operations/angular.json b/machine-operations/angular.json index 97e1d6a..acc2ed7 100644 --- a/machine-operations/angular.json +++ b/machine-operations/angular.json @@ -103,5 +103,8 @@ } } } + }, + "cli": { + "analytics": "15dead7e-04a1-4f40-8a32-c1cb1a5d23a0" } }