improved changes

This commit is contained in:
2025-11-26 20:01:52 +05:30
parent e5c5044ff3
commit 5ad65c3c59
68 changed files with 12520 additions and 827 deletions

View File

@ -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)

View File

@ -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
}
}
# 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}"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

View File

@ -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()

View File

@ -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()

View File

@ -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()
create_categories_table()

View File

@ -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

View File

@ -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<RoleUpdate | null>(null);
public roleUpdated$ = this._roleUpdated.asObservable();
// Track when a role is deleted
private _roleDeleted = new BehaviorSubject<number | null>(null);
public roleDeleted$ = this._roleDeleted.asObservable();
// Track when a role is created
private _roleCreated = new BehaviorSubject<RoleUpdate | null>(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);
}
}

View File

@ -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: [],
},
];

View File

@ -1 +1,326 @@
<p>branch-list works!</p>
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
<!-- Header -->
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
<!-- Loader -->
@if (isLoading) {
<div class="absolute inset-x-0 bottom-0">
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
</div>
}
<!-- Title -->
<div class="text-4xl font-extrabold tracking-tight">Branch Management</div>
<!-- Actions -->
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
<!-- Search -->
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
[placeholder]="'Search branches'" />
</mat-form-field>
<!-- Add branch button -->
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createBranch()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2 mr-1">Add Branch</span>
</button>
</div>
</div>
<!-- Main -->
<div class="flex flex-auto overflow-hidden">
<!-- Branches list -->
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
@if (branches$ | async; as branches) {
@if (branches.length > 0 || selectedBranch) {
<div class="grid">
<!-- Header -->
<div class="branch-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort matSortDisableClear>
<div class="hidden md:block" [mat-sort-header]="'branch_id'">Branch ID</div>
<div [mat-sort-header]="'code'">Code</div>
<div [mat-sort-header]="'name'">Branch Name</div>
<div class="hidden lg:block" [mat-sort-header]="'location'">Location</div>
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block">Created By</div>
<div class="text-center">Actions</div>
</div>
<!-- New Branch Form Row -->
@if (selectedBranch && !selectedBranch.id) {
<div class="branch-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
<!-- Branch ID -->
<div class="hidden truncate md:block">
<span class="text-xs font-medium text-blue-600">Auto-generated</span>
</div>
<!-- Code -->
<div class="flex items-center">
<div class="truncate font-medium text-blue-600">New Branch</div>
</div>
<!-- Name -->
<div class="hidden truncate text-blue-600 text-sm">Creating...</div>
<!-- Location -->
<div class="hidden truncate lg:block text-blue-600 text-sm">-</div>
<!-- Contact -->
<div class="hidden truncate lg:block text-blue-600 text-sm">-</div>
<!-- ⭐ NEW: Created By (Auto) -->
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>Auto</span>
</div>
<!-- Actions -->
<div class="flex items-center justify-center gap-2">
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
</button>
</div>
</div>
<!-- New Branch Form Details -->
<div class="grid">
<ng-container
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedBranch }"></ng-container>
</div>
}
<!-- Existing Branches -->
@for (branch of branches; track trackByFn($index, branch)) {
<div class="branch-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
<!-- Branch ID -->
<div class="hidden truncate md:block">
<span class="text-xs font-mono text-blue-600 font-semibold">{{ branch.branch_id }}</span>
</div>
<!-- Code -->
<div class="flex items-center">
<div class="truncate">
<div class="font-semibold text-gray-900">{{ branch.code }}</div>
<div class="text-xs text-gray-500 md:hidden">{{ branch.branch_id }}</div>
</div>
</div>
<!-- Name -->
<div class="truncate">
<div class="font-medium text-gray-900">{{ branch.name }}</div>
</div>
<!-- Location -->
<div class="hidden truncate lg:block">
<div class="text-sm text-gray-600">{{ branch.location }}</div>
</div>
<!-- Contact -->
<div class="hidden truncate lg:block">
<div class="text-sm text-gray-600">{{ branch.contact }}</div>
</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block flex items-center gap-1">
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>{{ branch.created_by_username || 'N/A' }}</span>
</div>
<!-- Actions -->
<div class="flex items-center justify-center gap-1">
<button mat-icon-button (click)="toggleDetails(branch.id)"
[matTooltip]="selectedBranch?.id === branch.id ? 'Hide Details' : 'View Details'">
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
selectedBranch?.id === branch.id
? 'heroicons_outline:chevron-up'
: 'heroicons_outline:chevron-down'
"></mat-icon>
</button>
<button mat-icon-button (click)="editBranch(branch)" matTooltip="Edit">
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
</button>
<button mat-icon-button (click)="deleteBranch(branch)" matTooltip="Delete">
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
</button>
</div>
</div>
<!-- Branch Details -->
@if (selectedBranch?.id === branch.id) {
<div class="grid">
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: branch }"></ng-container>
</div>
}
}
</div>
<mat-paginator
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
[showFirstLastButtons]="true"></mat-paginator>
} @else {
<!-- Empty State -->
<div class="flex flex-col items-center justify-center p-16">
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:building-office'"></mat-icon>
</div>
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No branches found</h3>
<p class="text-sm text-gray-500 mb-6">Get started by creating your first branch</p>
<button mat-flat-button [color]="'primary'" (click)="createBranch()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2">Add Branch</span>
</button>
</div>
}
}
<!-- Row Details Template -->
<ng-template #rowDetailsTemplate let-branch>
<div class="overflow-hidden bg-white border-b">
<div class="flex">
<form class="flex w-full flex-col" [formGroup]="selectedBranchForm">
<div class="p-8">
<!-- Header -->
<div class="mb-6 pb-6 border-b">
<h2 class="text-xl font-semibold text-gray-900">
{{ branch.id ? 'Edit Branch' : 'Create New Branch' }}
</h2>
@if (branch.branch_id) {
<p class="text-sm text-gray-500 mt-1">Branch ID: <span class="font-mono text-blue-600">{{ branch.branch_id }}</span></p>
}
</div>
<!-- Form Fields -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Branch Code -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch Code</mat-label>
<input matInput [formControlName]="'code'" placeholder="e.g., BR001" />
<mat-error *ngIf="selectedBranchForm.get('code')?.hasError('required')">
Code is required
</mat-error>
<mat-error *ngIf="selectedBranchForm.get('code')?.hasError('pattern')">
Use uppercase letters and numbers only
</mat-error>
<mat-hint>Unique branch identifier</mat-hint>
</mat-form-field>
<!-- Branch Name -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch Name</mat-label>
<input matInput [formControlName]="'name'" placeholder="e.g., Main Branch" />
<mat-error *ngIf="selectedBranchForm.get('name')?.hasError('required')">
Name is required
</mat-error>
<mat-error *ngIf="selectedBranchForm.get('name')?.hasError('minlength')">
Minimum 3 characters required
</mat-error>
</mat-form-field>
<!-- Location -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Location</mat-label>
<input matInput [formControlName]="'location'" placeholder="e.g., Mumbai" />
<mat-error *ngIf="selectedBranchForm.get('location')?.hasError('required')">
Location is required
</mat-error>
</mat-form-field>
<!-- Contact -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Contact Number</mat-label>
<input matInput type="tel" [formControlName]="'contact'" placeholder="9876543210" />
<mat-error *ngIf="selectedBranchForm.get('contact')?.hasError('required')">
Contact is required
</mat-error>
<mat-error *ngIf="selectedBranchForm.get('contact')?.hasError('pattern')">
Enter valid 10-digit number
</mat-error>
</mat-form-field>
<!-- Address - Full Width -->
<mat-form-field class="w-full md:col-span-2" appearance="outline">
<mat-label>Address</mat-label>
<textarea matInput [formControlName]="'address'" rows="3"
placeholder="Enter complete branch address"></textarea>
<mat-error *ngIf="selectedBranchForm.get('address')?.hasError('required')">
Address is required
</mat-error>
<mat-error *ngIf="selectedBranchForm.get('address')?.hasError('minlength')">
Minimum 10 characters required
</mat-error>
</mat-form-field>
<!-- ⭐ NEW: Created By Field (Read-only) -->
@if (branch.id && branch.created_by_username) {
<mat-form-field class="w-full" appearance="outline">
<mat-label>Created By</mat-label>
<input
matInput
[value]="branch.created_by_username"
readonly
/>
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
</mat-form-field>
}
</div>
<!-- Metadata -->
@if (branch.id) {
<div class="mt-6 pt-6 border-t">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Created:</span>
<span class="ml-2 text-gray-900">{{ branch.created_at | date:'medium' }}</span>
</div>
@if (branch.updated_at && branch.updated_at !== branch.created_at) {
<div>
<span class="text-gray-500">Last Updated:</span>
<span class="ml-2 text-gray-900">{{ branch.updated_at | date:'medium' }}</span>
</div>
}
</div>
</div>
}
</div>
<!-- Action Buttons -->
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
<button mat-button [color]="'warn'" (click)="deleteSelectedBranch()"
type="button" [disabled]="!branch.id">
Delete
</button>
<div class="flex items-center gap-3">
@if (flashMessage) {
<div class="flex items-center text-sm">
@if (flashMessage === 'success') {
<mat-icon class="icon-size-5 text-green-600 mr-2"
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
<span class="text-green-600">{{ branch.id ? 'Updated' : 'Created' }} successfully</span>
}
@if (flashMessage === 'error') {
<mat-icon class="icon-size-5 text-red-600 mr-2"
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
<span class="text-red-600">Error occurred</span>
}
</div>
}
<button mat-stroked-button (click)="closeDetails()" type="button">
Cancel
</button>
<button mat-flat-button [color]="'primary'" (click)="updateSelectedBranch()"
type="button" [disabled]="selectedBranchForm.invalid || isLoading">
@if (isLoading) {
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
}
<span>{{ branch.id ? 'Update' : 'Create' }}</span>
</button>
</div>
</div>
</form>
</div>
</div>
</ng-template>
</div>
</div>
</div>

View File

@ -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;
}

View File

@ -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<Branch[]>;
private branchesSubject = new BehaviorSubject<Branch[]>([]);
private destroy$ = new Subject<void>();
// 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'
});
}
}

View File

@ -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<Branch[]> {
return this.http.get<Branch[]>(this.apiUrl, { headers: this.getHeaders() })
.pipe(
catchError(this.handleError)
);
}
/**
* Get single branch by ID
*/
getBranch(id: number): Observable<Branch> {
return this.http.get<Branch>(`${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<Branch> {
return this.http.post<Branch>(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<Branch> {
return this.http.put<Branch>(`${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<Branch[]> {
return this.http.get<Branch[]>(`${this.apiUrl}/search?q=${encodeURIComponent(query)}`,
{ headers: this.getHeaders() })
.pipe(
catchError(this.handleError)
);
}
/**
* Handle HTTP errors
*/
private handleError(error: any): Observable<never> {
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
}));
}
}

View File

@ -1 +1,335 @@
<p>client-list works!</p>
<!-- client-list.component.html -->
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
<!-- Header -->
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
<!-- Loader -->
@if (isLoading) {
<div class="absolute inset-x-0 bottom-0">
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
</div>
}
<!-- Title -->
<div class="flex items-center gap-3">
<div class="text-4xl font-extrabold tracking-tight">Client Management</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{{ pagination.length }} Clients
</span>
</div>
<!-- Actions -->
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
<!-- Search -->
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
[placeholder]="'Search clients or refillers'" />
</mat-form-field>
<!-- Refresh button -->
<button class="ml-4" mat-stroked-button (click)="refresh()">
<mat-icon [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
<span class="ml-2 mr-1">Refresh</span>
</button>
</div>
</div>
<!-- Main -->
<div class="flex flex-auto overflow-hidden">
<!-- Clients list -->
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
@if (clients$ | async; as clients) {
@if (clients.length > 0 || selectedClient) {
<div class="grid">
<!-- Header - 8 COLUMNS -->
<div class="client-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort matSortDisableClear>
<div class="hidden md:block" [mat-sort-header]="'user_id'">User ID</div>
<div [mat-sort-header]="'username'">Client Name</div>
<div class="hidden sm:block" [mat-sort-header]="'email'">Email</div>
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
<div class="hidden lg:block">Status</div>
<div class="hidden xl:block" [mat-sort-header]="'machines_count'">Machine ID</div>
<div class="hidden xl:block" [mat-sort-header]="'refillers_count'">Refillers</div>
<div class="hidden sm:block">Details</div>
</div>
<!-- Client Rows -->
@for (client of clients; track trackByFn($index, client)) {
<div class="client-grid grid items-center gap-4 border-b px-6 py-3 md:px-8">
<!-- User ID -->
<div class="hidden truncate md:block">
<span class="font-mono text-xs text-gray-600">{{ client.user_id }}</span>
</div>
<!-- Client Name -->
<div class="flex items-center">
<div class="relative mr-3 flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full border bg-gray-200">
@if (client.photo) {
<img [src]="getFullFileUrl(client.photo)" [alt]="client.username"
class="h-full w-full object-cover">
} @else {
<span class="text-sm font-semibold uppercase">
{{ client.username.charAt(0) }}
</span>
}
</div>
<div class="truncate">
<div class="font-medium">{{ client.username }}</div>
@if (client.user_id) {
<div class="text-xs text-gray-500">{{ client.user_id }}</div>
}
</div>
</div>
<!-- Email -->
<div class="hidden truncate sm:block">
{{ client.email }}
</div>
<!-- Contact -->
<div class="hidden truncate lg:block">
{{ client.contact }}
</div>
<!-- Status -->
<div class="hidden lg:block">
@if (client.user_status === 'Active') {
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
<span class="mr-1 h-2 w-2 rounded-full bg-green-400"></span>
Active
</span>
}
@if (client.user_status === 'Inactive') {
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">
<span class="mr-1 h-2 w-2 rounded-full bg-gray-400"></span>
Inactive
</span>
}
</div>
<!-- Machine IDs -->
<div class="hidden xl:block">
@if (client.machines && client.machines.length > 0) {
<div class="flex flex-col gap-1">
@for (machine of client.machines; track machine.machine_id) {
<div class="text-xs">
<span class="font-mono font-semibold text-blue-600">{{ machine.machine_id }}</span>
</div>
}
</div>
} @else {
<span class="text-gray-400 text-xs">No machines</span>
}
</div>
<!-- Refillers -->
<div class="hidden xl:block">
@if (client.refillers && client.refillers.length > 0) {
<div class="flex flex-col gap-1">
@for (refiller of client.refillers; track refiller.id) {
<div class="text-xs">
<span class="font-medium text-green-700">{{ refiller.username }}</span>
@if (refiller.assigned_machines_count > 0) {
<span class="text-gray-500"> ({{ refiller.assigned_machines_count }} machines)</span>
}
</div>
}
</div>
} @else {
<span class="text-gray-400 text-xs">No refillers</span>
}
</div>
<!-- Details button -->
<div class="hidden sm:block">
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
(click)="toggleDetails(client.id)">
<mat-icon class="icon-size-5" [svgIcon]="
selectedClient?.id === client.id
? 'heroicons_solid:chevron-up'
: 'heroicons_solid:chevron-down'
"></mat-icon>
</button>
</div>
</div>
<!-- Client Details -->
@if (selectedClient?.id === client.id) {
<div class="grid">
<ng-container *ngTemplateOutlet="clientDetailsTemplate; context: { $implicit: client }"></ng-container>
</div>
}
}
</div>
<mat-paginator
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
[showFirstLastButtons]="true"></mat-paginator>
} @else {
<div class="border-t p-8 text-center text-4xl font-semibold tracking-tight sm:p-16">
No clients found!
</div>
}
}
<!-- Client Details Template -->
<ng-template #clientDetailsTemplate let-client>
<div class="overflow-hidden shadow-lg">
<div class="flex border-b">
<div class="flex w-full flex-col p-8">
<!-- Client Header -->
<div class="flex items-start justify-between mb-6 pb-6 border-b">
<div class="flex items-center gap-4">
<!-- Photo -->
<div class="relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-4 bg-gray-200">
@if (client.photo) {
<img [src]="getFullFileUrl(client.photo)" [alt]="client.username"
class="h-full w-full object-cover">
} @else {
<span class="text-3xl font-bold uppercase">
{{ client.username.charAt(0) }}
</span>
}
</div>
<!-- Client Info -->
<div>
<h3 class="text-2xl font-bold text-gray-900">{{ client.username }}</h3>
<p class="text-sm text-gray-600 font-mono">{{ client.user_id }}</p>
<p class="text-sm text-gray-600 mt-1">{{ client.email }}</p>
<p class="text-sm text-gray-600">{{ client.contact }}</p>
</div>
</div>
<!-- Company Logo -->
@if (client.company_logo) {
<div class="flex flex-col items-center">
<span class="text-xs text-gray-600 mb-2">Company Logo</span>
<div class="h-16 w-16 rounded-lg border-2 bg-white p-2">
<img [src]="getFullFileUrl(client.company_logo)" alt="Company logo"
class="h-full w-full object-contain">
</div>
</div>
}
</div>
<!-- Machines Section -->
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h4 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<mat-icon class="text-blue-600" [svgIcon]="'heroicons_outline:cog'"></mat-icon>
Machines ({{ client.machines_count }})
</h4>
</div>
@if (client.machines && client.machines.length > 0) {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@for (machine of client.machines; track machine.machine_id) {
<div class="p-4 bg-gray-50 rounded-lg border hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<div class="font-semibold text-gray-900">{{ machine.machine_id }}</div>
<div class="text-sm text-gray-600">{{ machine.machine_model }}</div>
<div class="text-xs text-gray-500 mt-1">{{ machine.machine_type }}</div>
</div>
<mat-icon class="text-gray-400">devices</mat-icon>
</div>
<div class="mt-3 flex items-center justify-between">
<div class="text-xs text-gray-600">
<mat-icon class="icon-size-3 inline" [svgIcon]="'heroicons_outline:map-pin'"></mat-icon>
{{ machine.branch_name }}
</div>
<span [class]="'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + getStatusClass(machine.operation_status)">
{{ machine.operation_status }}
</span>
</div>
<div class="mt-2">
<span [class]="'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + getStatusClass(machine.connection_status)">
<span class="mr-1 h-1.5 w-1.5 rounded-full" [class]="machine.connection_status === 'online' ? 'bg-green-400' : 'bg-gray-400'"></span>
{{ machine.connection_status }}
</span>
</div>
</div>
}
</div>
} @else {
<div class="text-center py-8 px-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
<mat-icon class="text-gray-400 mb-2" style="font-size: 48px; width: 48px; height: 48px;">devices_other</mat-icon>
<p class="text-sm text-gray-600">No machines assigned yet</p>
</div>
}
</div>
<!-- Refillers Section -->
<div>
<div class="flex items-center justify-between mb-4">
<h4 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<mat-icon class="text-green-600" [svgIcon]="'heroicons_outline:users'"></mat-icon>
Assigned Refillers ({{ client.refillers_count }})
</h4>
</div>
@if (client.refillers && client.refillers.length > 0) {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (refiller of client.refillers; track refiller.id) {
<div class="p-4 bg-green-50 rounded-lg border border-green-200 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="font-semibold text-gray-900">{{ refiller.username }}</div>
<div class="text-sm text-gray-600">{{ refiller.email }}</div>
<div class="text-xs text-gray-500 mt-1">{{ refiller.contact }}</div>
<div class="text-xs text-gray-500 font-mono">{{ refiller.user_id }}</div>
</div>
<mat-icon class="text-green-600">person</mat-icon>
</div>
<!-- Refiller's Machines -->
@if (refiller.assigned_machines_count > 0) {
<div class="mt-3 pt-3 border-t border-green-200">
<div class="text-xs font-medium text-gray-700 mb-2">
Assigned Machines ({{ refiller.assigned_machines_count }})
</div>
<div class="space-y-1">
@for (machine of refiller.assigned_machines; track machine.machine_id) {
<div class="flex items-center justify-between text-xs bg-white px-2 py-1.5 rounded">
<span class="font-mono font-medium">{{ machine.machine_id }}</span>
<span class="text-gray-500">{{ machine.machine_model }}</span>
</div>
}
</div>
</div>
} @else {
<div class="mt-3 pt-3 border-t border-green-200 text-xs text-gray-500">
No machines assigned
</div>
}
</div>
}
</div>
} @else {
<div class="text-center py-8 px-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
<mat-icon class="text-gray-400 mb-2" style="font-size: 48px; width: 48px; height: 48px;">group</mat-icon>
<p class="text-sm text-gray-600">No refillers assigned yet</p>
</div>
}
</div>
<!-- Close Button -->
<div class="flex justify-end mt-6 pt-6 border-t">
<button mat-stroked-button (click)="closeDetails()">
<mat-icon>close</mat-icon>
<span class="ml-1">Close</span>
</button>
</div>
</div>
</div>
</div>
</ng-template>
</div>
</div>
</div>

View File

@ -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));
}
}

View File

@ -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<ClientDetail[]>;
private _unsubscribeAll: Subject<any> = new Subject<any>();
// Data
dataSource = new MatTableDataSource<ClientDetail>();
private _clients: BehaviorSubject<ClientDetail[]> = new BehaviorSubject<ClientDetail[]>([]);
// 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();
}
}

View File

@ -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<ClientDetail[]> {
// First get all users
return this.http.get<any[]>(`${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<any[]>(`${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<ClientDetail> {
// Fetch client, users, and machines in parallel
return this.http.get<any>(`${this.apiUrl}/users/${clientId}`).pipe(
switchMap(client => {
// Get all users and machines
return this.http.get<any[]>(`${this.apiUrl}/users`).pipe(
switchMap(users => {
return this.http.get<any[]>(`${this.apiUrl}/machines`).pipe(
map(machines => this.transformToClientDetail(client, users, machines))
);
})
);
})
);
}
/**
* Get machines for a specific client
*/
getClientMachines(clientId: number): Observable<ClientMachine[]> {
return this.http.get<ClientMachine[]>(`${this.apiUrl}/clients/${clientId}/machines`);
}
/**
* Get refillers assigned to a specific client
*/
getClientRefillers(clientId: number): Observable<ClientRefiller[]> {
return this.http.get<ClientRefiller[]>(`${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;
}
}

View File

@ -1 +1,268 @@
<p>company-admin-list works!</p>
<!-- company-admin-list.component.html -->
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
<!-- Header -->
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
<!-- Loader -->
@if (isLoading) {
<div class="absolute inset-x-0 bottom-0">
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
</div>
}
<!-- Title -->
<div class="text-4xl font-extrabold tracking-tight">Company Admin Management</div>
<!-- Actions -->
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
<!-- Search -->
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
[placeholder]="'Search admins'" />
</mat-form-field>
<!-- Refresh button -->
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="refreshAdmins()">
<mat-icon [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
<span class="ml-2 mr-1">Refresh</span>
</button>
</div>
</div>
<!-- Main -->
<div class="flex flex-auto overflow-hidden">
<!-- Admin list -->
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
@if (admins$ | async; as admins) {
@if (filteredAdmins.length > 0 || searchInputControl.value) {
<div class="grid">
<!-- Header - 7 COLUMNS: User ID, Name, Email, Contact, Role, Status, Details -->
<div class="admin-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort matSortDisableClear (matSortChange)="onSortChange($event)">
<div class="hidden md:block" [mat-sort-header]="'user_id'">User ID</div>
<div [mat-sort-header]="'username'">Admin Name</div>
<div class="hidden sm:block" [mat-sort-header]="'email'">Email</div>
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
<div class="hidden lg:block" [mat-sort-header]="'roles'">Role</div>
<div class="hidden lg:block" [mat-sort-header]="'user_status'">Status</div>
<div class="hidden sm:block">Details</div>
</div>
<!-- Admin Rows -->
@for (admin of filteredAdmins; track trackByFn($index, admin)) {
<div class="admin-grid grid items-center gap-4 border-b px-6 py-3 md:px-8">
<!-- User ID -->
<div class="hidden truncate md:block">
<span class="font-mono text-xs text-gray-600">{{ admin.user_id }}</span>
</div>
<!-- Admin Name with Avatar -->
<div class="flex items-center">
<div class="relative mr-3 flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full border bg-gray-200">
@if (admin.photo) {
<img [src]="getFullFileUrl(admin.photo)" [alt]="admin.username"
class="h-full w-full object-cover">
} @else {
<span class="text-sm font-semibold uppercase">
{{ admin.username.charAt(0) }}
</span>
}
</div>
<div class="truncate">
<div class="font-medium">{{ admin.username }}</div>
@if (admin.created_by_username) {
<div class="text-xs text-gray-500 flex items-center gap-1">
<mat-icon class="icon-size-3">person_add</mat-icon>
<span>by {{ admin.created_by_username }}</span>
</div>
}
</div>
</div>
<!-- Email -->
<div class="hidden truncate sm:block">
{{ admin.email }}
</div>
<!-- Contact -->
<div class="hidden truncate lg:block">
{{ admin.contact }}
</div>
<!-- Role -->
<div class="hidden lg:block">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" [ngClass]="{
'bg-orange-100 text-orange-800': admin.roles === 'SuperAdmin',
'bg-amber-100 text-amber-800': admin.roles === 'Management',
'bg-red-100 text-red-800': admin.roles === 'Admin'
}">
{{ admin.roles }}
</span>
</div>
<!-- Status -->
<div class="hidden lg:block">
@if (admin.user_status === 'Active') {
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
<span class="mr-1 h-2 w-2 rounded-full bg-green-400 animate-pulse"></span>
Active
</span>
}
@if (admin.user_status === 'Inactive') {
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">
<span class="mr-1 h-2 w-2 rounded-full bg-gray-400"></span>
Inactive
</span>
}
</div>
<!-- Details button -->
<div class="hidden sm:block">
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
(click)="toggleDetails(admin.id)">
<mat-icon class="icon-size-5" [svgIcon]="
selectedAdmin?.id === admin.id
? 'heroicons_solid:chevron-up'
: 'heroicons_solid:chevron-down'
"></mat-icon>
</button>
</div>
</div>
<!-- Admin Details Panel -->
@if (selectedAdmin?.id === admin.id) {
<div class="grid bg-gray-50 dark:bg-gray-900">
<div class="border-b p-8">
<!-- Admin Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<div class="relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border-4 border-white shadow-lg bg-gray-200">
@if (selectedAdmin.photo) {
<img [src]="getFullFileUrl(selectedAdmin.photo)" [alt]="selectedAdmin.username"
class="h-full w-full object-cover">
} @else {
<span class="text-3xl font-bold uppercase">
{{ selectedAdmin.username.charAt(0) }}
</span>
}
</div>
<div>
<h3 class="text-2xl font-bold">{{ selectedAdmin.username }}</h3>
<p class="text-sm text-gray-600">{{ selectedAdmin.user_id }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" [ngClass]="{
'bg-orange-100 text-orange-800': selectedAdmin.roles === 'SuperAdmin',
'bg-amber-100 text-amber-800': selectedAdmin.roles === 'Management',
'bg-red-100 text-red-800': selectedAdmin.roles === 'Admin'
}">
{{ selectedAdmin.roles }}
</span>
@if (selectedAdmin.user_status === 'Active') {
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
<span class="mr-1 h-2 w-2 rounded-full bg-green-400 animate-pulse"></span>
Active
</span>
}
</div>
</div>
</div>
@if (selectedAdmin.company_logo) {
<div class="flex items-center">
<img [src]="getFullFileUrl(selectedAdmin.company_logo)" alt="Company logo"
class="h-16 w-auto object-contain">
</div>
}
</div>
<!-- Admin Information Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Contact Information -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<mat-icon class="icon-size-5 text-blue-600">contact_mail</mat-icon>
Contact Information
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<mat-icon class="icon-size-4 text-gray-500">email</mat-icon>
<span class="text-sm">{{ selectedAdmin.email }}</span>
</div>
<div class="flex items-center gap-2">
<mat-icon class="icon-size-4 text-gray-500">phone</mat-icon>
<span class="text-sm">{{ selectedAdmin.contact }}</span>
</div>
</div>
</div>
<!-- Created By -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<mat-icon class="icon-size-5 text-green-600">person_add</mat-icon>
Created By
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<mat-icon class="icon-size-4 text-gray-500">account_circle</mat-icon>
<span class="text-sm font-medium">{{ selectedAdmin.created_by_username || 'System' }}</span>
</div>
<div class="flex items-center gap-2">
<mat-icon class="icon-size-4 text-gray-500">calendar_today</mat-icon>
<span class="text-sm">{{ selectedAdmin.created_at | date:'short' }}</span>
</div>
</div>
</div>
<!-- Account Status -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<mat-icon class="icon-size-5 text-purple-600">info</mat-icon>
Account Status
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<mat-icon class="icon-size-4 text-gray-500">verified_user</mat-icon>
<span class="text-sm">{{ selectedAdmin.user_status }}</span>
</div>
@if (selectedAdmin.updated_at) {
<div class="flex items-center gap-2">
<mat-icon class="icon-size-4 text-gray-500">update</mat-icon>
<span class="text-sm">Updated: {{ selectedAdmin.updated_at | date:'short' }}</span>
</div>
}
</div>
</div>
</div>
<!-- Close Button -->
<div class="mt-6 flex justify-end">
<button mat-flat-button [color]="'primary'" (click)="closeDetails()">
<mat-icon>close</mat-icon>
<span class="ml-2">Close</span>
</button>
</div>
</div>
</div>
}
}
</div>
<!-- Pagination -->
<mat-paginator
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
[ngClass]="{ 'pointer-events-none': isLoading }"
[length]="pagination.length"
[pageIndex]="pagination.page"
[pageSize]="pagination.size"
[pageSizeOptions]="pagination.pageSizeOptions"
[showFirstLastButtons]="true"
(page)="onPageChange($event)">
</mat-paginator>
} @else {
<div class="border-t p-8 text-center text-4xl font-semibold tracking-tight sm:p-16">
No admin users found!
</div>
}
}
</div>
</div>
</div>

View File

@ -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;
}
}

View File

@ -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<AdminDetail[]>;
private _unsubscribeAll: Subject<any> = new Subject<any>();
// Data
private _admins: BehaviorSubject<AdminDetail[]> = new BehaviorSubject<AdminDetail[]>([]);
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;
}
}

View File

@ -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<AdminDetail[]> {
return this.http.get<any[]>(`${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<AdminDetail> {
return this.http.get<any>(`${this.apiUrl}/users/${adminId}`).pipe(
switchMap(admin => {
return this.http.get<any[]>(`${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}`;
}
}

View File

@ -1,12 +1,15 @@
<!-- src/app/machines/machine-details/machine-details.component.html -->
<div class="flex-auto p-6 sm:p-10 overflow-auto max-h-[calc(100vh-100px)]">
<div class="machine-details-container">
<!-- Header Bar -->
<div class="header-bar">
<button class="back-button" (click)="goBack()">
<i class="fa fa-arrow-left"></i> Back
</button>
<div class="header-actions">
<button class="save-button" (click)="saveChanges()">
<i class="fa fa-save"></i> Save Changes
<button class="save-button" (click)="saveChanges()" [disabled]="isLoading">
<i class="fa fa-save"></i>
{{ isLoading ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</div>
@ -17,8 +20,13 @@
<button class="retry-button" (click)="loadMachineData()">Retry</button>
</div>
<!-- Loading Spinner -->
<div *ngIf="isLoading" class="loading-spinner">
<i class="fa fa-spinner fa-spin"></i> Loading...
</div>
<!-- Information section -->
<div class="info-card" *ngIf="!errorMessage">
<div class="info-card" *ngIf="!errorMessage && !isLoading">
<h3>Information</h3>
<div class="info-grid">
<div class="info-item">
@ -61,7 +69,7 @@
</div>
<!-- Vending rows container -->
<div class="vending-container" *ngIf="!errorMessage">
<div class="vending-container" *ngIf="!errorMessage && !isLoading">
<div *ngFor="let row of vendingRows" class="vending-row">
<div class="row-title">
<span>{{row.name}}</span>
@ -73,19 +81,28 @@
</div>
<div class="slots-container">
<div *ngFor="let slot of row.slots" class="slot-card" [ngClass]="{'disabled': !slot.enabled}">
<div *ngFor="let slot of row.slots"
class="slot-card"
[ngClass]="{'disabled': !slot.enabled}">
<!-- Slot Header -->
<div class="slot-header">
<span class="slot-name">{{slot.name}}</span>
<div class="toggle-container">
<label class="switch">
<input type="checkbox" [checked]="slot.enabled" (click)="toggleSlotStatus(row, slot, $event)">
<input type="checkbox"
[checked]="slot.enabled"
(click)="toggleSlotStatus(row, slot, $event)">
<span class="slider"></span>
</label>
<span class="status-text" [ngClass]="{'enabled': slot.enabled, 'disabled': !slot.enabled}">
<span class="status-text"
[ngClass]="{'enabled': slot.enabled, 'disabled': !slot.enabled}">
{{slot.enabled ? 'Enabled' : 'Disabled'}}
</span>
</div>
</div>
<!-- Slot Body -->
<div class="slot-body" (click)="openProductSelector(row, slot, $event)">
<ng-container *ngIf="!slot.product; else productAssigned">
<div class="empty-slot">
@ -95,16 +112,27 @@
<p class="add-product-text">Assign Product</p>
</div>
</ng-container>
<ng-template #productAssigned>
<div class="product-image-container">
<img *ngIf="slot.product?.product_image"
[src]="'https://iotbackend.rootxwire.com/' + slot.product?.product_image"
[attr.alt]="slot.product?.product_name" class="product-image">
<!-- Show image if valid -->
<img *ngIf="hasValidImage(slot.product?.product_image)"
[src]="getImageUrl(slot.product?.product_image)"
[attr.alt]="slot.product?.product_name"
class="product-image"
(error)="onImageError($event, slot.product?.product_image)">
<!-- Show gallery icon if no valid image -->
<div *ngIf="!hasValidImage(slot.product?.product_image)"
class="gallery-icon-container">
<i class="fa fa-image gallery-icon"></i>
</div>
</div>
<div class="product-details">
<div class="product-header">
<p class="product-name">{{slot.product.product_name}}</p>
<button class="remove-product" (click)="removeProductFromSlot(slot, $event)">
<p class="product-name">{{slot.product?.product_name}}</p>
<button class="remove-product"
(click)="removeProductFromSlot(slot, $event)">
<i class="fa fa-times"></i>
</button>
</div>
@ -122,7 +150,7 @@
</div>
<!-- Product Selector Modal -->
<div *ngIf="showProductSelector" class="modal-overlay">
<div *ngIf="showProductSelector" class="modal-overlay" (click)="closeProductSelector()">
<div class="product-selector-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>Select Product for {{selectedSlot?.name}}</h3>
@ -132,56 +160,92 @@
</div>
<div class="modal-body">
<!-- Search Bar -->
<div class="search-container">
<input type="text" placeholder="Search products..." class="search-input" [(ngModel)]="searchTerm"
(input)="filterProducts()">
<input type="text"
placeholder="Search products..."
class="search-input"
[(ngModel)]="searchTerm"
(input)="filterProducts()">
</div>
<!-- Products Table -->
<div class="products-table">
<div *ngFor="let product of filteredProducts; let i = index" class="product-row"
[ngClass]="{'selected': product.selected, 'odd': i % 2 === 0}"
(click)="onProductSelectionChange(product)">
<div *ngFor="let product of filteredProducts; let i = index"
class="product-row"
[ngClass]="{'selected': product.selected, 'odd': i % 2 === 0}"
(click)="onProductSelectionChange(product)">
<!-- Checkbox -->
<div class="checkbox-cell">
<input type="checkbox" [checked]="product.selected" [id]="'product-' + product.product_id"
(click)="$event.stopPropagation(); onProductSelectionChange(product);">
<input type="checkbox"
[checked]="product.selected"
[id]="'product-' + product.product_id"
(click)="$event.stopPropagation(); onProductSelectionChange(product)">
</div>
<!-- Product ID -->
<div class="number-cell">{{product.product_id}}</div>
<!-- Product Name -->
<div class="product-name-cell">{{product.product_name}}</div>
<!-- Price -->
<div class="price-cell">₹{{product.price}}</div>
<!-- Image -->
<div class="image-cell">
<img *ngIf="product.product_image" [src]="'http://localhost:5000/' + product.product_image"
alt="{{product.product_name}}" class="product-thumbnail">
<!-- Show image if valid -->
<img *ngIf="hasValidImage(product.product_image)"
[src]="getImageUrl(product.product_image)"
[attr.alt]="product.product_name"
class="product-thumbnail"
(error)="onImageError($event, product.product_image)">
<!-- Show gallery icon if no valid image -->
<div *ngIf="!hasValidImage(product.product_image)"
class="gallery-icon-thumbnail">
<i class="fa fa-image"></i>
</div>
</div>
<!-- Quantity Spinner (only for selected product) -->
<div class="quantity-cell">
<div class="quantity-spinner" *ngIf="product.selected">
<button class="quantity-btn" [disabled]="productQuantity <= 1"
(click)="$event.stopPropagation(); decreaseQuantity()">
<button class="quantity-btn"
[disabled]="productQuantity <= 1"
(click)="$event.stopPropagation(); decreaseQuantity()">
<i class="fa fa-minus"></i>
</button>
<input type="number" class="quantity-input" [(ngModel)]="productQuantity" min="1"
(click)="$event.stopPropagation()" (change)="setQuantity($event)">
<button class="quantity-btn" (click)="$event.stopPropagation(); increaseQuantity()">
<input type="number"
class="quantity-input"
[(ngModel)]="productQuantity"
min="1"
(click)="$event.stopPropagation()"
(change)="setQuantity($event)">
<button class="quantity-btn"
(click)="$event.stopPropagation(); increaseQuantity()">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</div>
<!-- No Products Found -->
<div *ngIf="filteredProducts.length === 0" class="no-products">
No products found matching "{{searchTerm}}"
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button class="btn-cancel" (click)="closeProductSelector()">Cancel</button>
<button class="btn-add" [disabled]="!selectedProduct" (click)="confirmProductSelection()">
<button class="btn-cancel" (click)="closeProductSelector()">
Cancel
</button>
<button class="btn-add"
[disabled]="!selectedProduct"
(click)="confirmProductSelection()">
Add Product
</button>
</div>

View File

@ -110,6 +110,65 @@ $white: #fff;
}
}
// src/app/machines/machine-details/machine-details.component.scss
// Gallery icon container (in slot card)
.gallery-icon-container {
width: 100%;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
.gallery-icon {
font-size: 48px;
color: #bbb;
}
}
// Gallery icon in product selector modal thumbnail
.gallery-icon-thumbnail {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 4px;
i {
font-size: 24px;
color: #bbb;
}
}
// Existing styles...
.product-image-container {
width: 100%;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 8px;
background: #f9f9f9;
}
.product-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.product-thumbnail {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 4px;
}
// Vending container & row
.vending-container {
display: flex;

View File

@ -1,10 +1,26 @@
// src/app/machines/machine-details/machine-details.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { MachineService, Machine } from '../machine.service';
import { ProductService } from '../../products/product.service';
import { ProductService, Product } from '../../products/product.service';
import { Subscription } from 'rxjs';
import { environment } from '@environments/environment';
interface Slot {
name: string;
enabled: boolean;
units: number;
price: string | number;
product: Product | null;
}
interface VendingRow {
name: string;
id: string;
slots: Slot[];
}
@Component({
selector: 'app-machine-details',
@ -26,7 +42,7 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
connection_status: ''
};
vendingRows = ['A', 'B', 'C', 'D', 'E', 'F'].map(rowId => ({
vendingRows: VendingRow[] = ['A', 'B', 'C', 'D', 'E', 'F'].map(rowId => ({
name: `Row ${rowId}`,
id: rowId,
slots: Array.from({ length: 10 }, (_, i) => ({
@ -38,16 +54,21 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
}))
}));
products = [];
filteredProducts = [];
products: Product[] = [];
filteredProducts: Product[] = [];
searchTerm = '';
showProductSelector = false;
selectedSlot = null;
selectedRow = null;
selectedProduct = null;
selectedSlot: Slot | null = null;
selectedRow: VendingRow | null = null;
selectedProduct: Product | null = null;
productQuantity = 1;
private subscriptions: Subscription[] = [];
errorMessage: string | null = null; // To display error messages on the UI
errorMessage: string | null = null;
isLoading = false;
// Track image loading errors
imageLoadErrors = new Set<string>();
constructor(
private machineService: MachineService,
@ -65,67 +86,78 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
/**
* Load machine data from route or service
*/
loadMachineData(): void {
// First, try to get the machine ID from the route
const machineId = this.route.snapshot.paramMap.get('id');
if (machineId) {
this.machineService.getMachineById(Number(machineId)).subscribe(
fetchedMachine => {
this.isLoading = true;
this.machineService.getMachineById(Number(machineId)).subscribe({
next: (fetchedMachine) => {
if (fetchedMachine?.machine_id) {
this.machine = fetchedMachine;
this.machineService.setSelectedMachine(fetchedMachine);
this.loadVendingState(fetchedMachine.machine_id);
this.errorMessage = null; // Clear any previous error
this.errorMessage = null;
} else {
console.warn('Could not find machine with ID:', machineId);
this.errorMessage = 'Machine not found. Please select a machine from the list.';
this.loadFallbackMachine();
}
this.isLoading = false;
},
error => {
error: (error) => {
console.error('Error fetching machine:', error);
this.errorMessage = 'Error loading machine details. Please try again.';
this.isLoading = false;
this.loadFallbackMachine();
}
);
});
} else {
// If no ID in route, try to load from selected machine
this.loadFallbackMachine();
}
}
/**
* Fallback to selected machine from service
*/
private loadFallbackMachine(): void {
const sub = this.machineService.selectedMachine$.subscribe(machine => {
if (machine?.machine_id) {
this.machine = machine;
this.loadVendingState(machine.machine_id);
// Update the URL with the machine ID to handle refresh
this.router.navigate(['/machines', machine.id], {
replaceUrl: true,
skipLocationChange: false
});
this.errorMessage = null; // Clear any previous error
this.errorMessage = null;
} else {
console.warn('No machine selected and no ID in route');
this.errorMessage = 'No machine selected. Please select a machine from the list.';
// Instead of navigating away, show an error message on the UI
}
});
this.subscriptions.push(sub);
}
/**
* Load vending state for machine
*/
loadVendingState(machineId: string): void {
this.machineService.getMachineVendingState(machineId).subscribe(
response => {
this.machineService.getMachineVendingState(machineId).subscribe({
next: (response) => {
if (response?.vendingRows) {
this.updateSlotsFromMachine(response.vendingRows);
}
},
error => console.error('Error loading vending state:', error)
);
error: (error) => console.error('Error loading vending state:', error)
});
}
/**
* Update local slots with data from backend
*/
updateSlotsFromMachine(machineSlots: any[]): void {
if (!machineSlots?.length) return;
@ -133,7 +165,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
const rowIndex = this.vendingRows.findIndex(r => r.id === rowData.rowId);
if (rowIndex !== -1 && rowData.slots) {
rowData.slots.forEach((slotData: any) => {
const slotIndex = this.vendingRows[rowIndex].slots.findIndex(s => s.name === slotData.name);
const slotIndex = this.vendingRows[rowIndex].slots.findIndex(
s => s.name === slotData.name
);
if (slotIndex !== -1) {
const slot = this.vendingRows[rowIndex].slots[slotIndex];
slot.enabled = slotData.enabled;
@ -151,24 +185,71 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
});
}
/**
* Load all products
*/
loadProducts(): void {
this.productService.getProducts().subscribe(
data => {
this.productService.getProducts().subscribe({
next: (data) => {
this.products = data;
if (this.machine?.machine_id) {
this.loadVendingState(this.machine.machine_id);
}
},
error => console.error('Error loading products:', error)
);
error: (error) => console.error('Error loading products:', error)
});
}
toggleSlotStatus(row: any, slot: any, event: MouseEvent): void {
/**
* Get full image URL for a product
*/
getImageUrl(imagePath: string | null | undefined): string {
if (!imagePath) {
return '';
}
// Remove leading slash if present
const cleanPath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
return `${environment.apiUrl}/${cleanPath}`;
}
/**
* Check if image has a valid URL
*/
hasValidImage(imagePath: string | null | undefined): boolean {
if (!imagePath) return false;
const imageUrl = this.getImageUrl(imagePath);
return imageUrl !== '' && !this.imageLoadErrors.has(imageUrl);
}
/**
* Handle image loading errors
*/
onImageError(event: any, imagePath: string | null | undefined): void {
if (imagePath) {
const imageUrl = this.getImageUrl(imagePath);
console.error('Image failed to load:', imageUrl);
this.imageLoadErrors.add(imageUrl);
// Hide the image by setting display to none
event.target.style.display = 'none';
}
}
/**
* Toggle slot enabled/disabled status
*/
toggleSlotStatus(row: VendingRow, slot: Slot, event: MouseEvent): void {
event.stopPropagation();
slot.enabled = !slot.enabled;
}
openProductSelector(row: any, slot: any, event: Event): void {
/**
* Open product selector modal
*/
openProductSelector(row: VendingRow, slot: Slot, event: Event): void {
if (!slot.enabled) return;
event.stopPropagation();
@ -177,10 +258,14 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
this.showProductSelector = true;
this.filteredProducts = [...this.products];
// Reset selection states
this.filteredProducts.forEach(p => p.selected = false);
// Pre-select product if slot has one
if (slot.product) {
const existingProduct = this.filteredProducts.find(p => p.product_id === slot.product.product_id);
const existingProduct = this.filteredProducts.find(
p => p.product_id === slot.product?.product_id
);
if (existingProduct) {
existingProduct.selected = true;
this.selectedProduct = existingProduct;
@ -195,6 +280,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
this.filterProducts();
}
/**
* Close product selector modal
*/
closeProductSelector(): void {
this.showProductSelector = false;
this.selectedRow = null;
@ -203,11 +291,16 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
this.productQuantity = 1;
}
/**
* Filter products based on search term
*/
filterProducts(): void {
if (!this.searchTerm.trim()) {
this.filteredProducts = [...this.products];
if (this.selectedProduct) {
const selectedProduct = this.filteredProducts.find(p => p.product_id === this.selectedProduct.product_id);
const selectedProduct = this.filteredProducts.find(
p => p.product_id === this.selectedProduct?.product_id
);
if (selectedProduct) selectedProduct.selected = true;
}
return;
@ -216,21 +309,27 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
const term = this.searchTerm.toLowerCase().trim();
this.filteredProducts = this.products.filter(product =>
product.product_name.toLowerCase().includes(term) ||
product.product_id.toString().includes(term) ||
product.product_id?.toString().includes(term) ||
(product.category && product.category.toLowerCase().includes(term))
);
if (this.selectedProduct) {
const selectedProduct = this.filteredProducts.find(p => p.product_id === this.selectedProduct.product_id);
const selectedProduct = this.filteredProducts.find(
p => p.product_id === this.selectedProduct?.product_id
);
if (selectedProduct) selectedProduct.selected = true;
}
}
onProductSelectionChange(product: any): void {
/**
* Handle product selection change
*/
onProductSelectionChange(product: Product): void {
this.filteredProducts.forEach(p => p.selected = false);
product.selected = true;
this.selectedProduct = product;
// Pre-fill quantity if same product is already in slot
if (this.selectedSlot?.product?.product_id === product.product_id) {
this.productQuantity = this.selectedSlot.units || 1;
} else {
@ -238,19 +337,26 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
}
}
/**
* Increase product quantity
*/
increaseQuantity(): void {
this.productQuantity++;
}
/**
* Decrease product quantity
*/
decreaseQuantity(): void {
// ✅ Allow decreasing quantity (minimum 1)
if (this.productQuantity > 1) {
this.productQuantity--;
}
}
/**
* Set quantity from input field
*/
setQuantity(event: any): void {
// ✅ Allow setting quantity always
const value = parseInt(event.target.value);
this.productQuantity = (!isNaN(value) && value > 0) ? value : 1;
if (this.productQuantity < 1) {
@ -259,6 +365,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
}
}
/**
* Confirm product selection and assign to slot
*/
confirmProductSelection(): void {
if (!this.selectedProduct || !this.selectedSlot) return;
@ -269,13 +378,19 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
this.closeProductSelector();
}
removeProductFromSlot(slot: any, event: Event): void {
/**
* Remove product from slot
*/
removeProductFromSlot(slot: Slot, event: Event): void {
event.stopPropagation();
slot.product = null;
slot.units = 0;
slot.price = 'N/A';
}
/**
* Save all changes to backend
*/
saveChanges(): void {
if (!this.machine.machine_id) {
console.error('Cannot save: No machine ID available');
@ -294,21 +409,27 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
}))
}));
this.isLoading = true;
this.machineService.updateMachineVendingState(this.machine.machine_id, vendingState)
.subscribe(
response => {
.subscribe({
next: (response) => {
console.log('Vending state updated:', response);
alert('Machine configuration saved successfully!');
this.errorMessage = null;
this.isLoading = false;
},
error => {
error: (error) => {
console.error('Error updating vending state:', error);
this.errorMessage = 'Failed to save machine configuration. Please try again.';
this.isLoading = false;
}
);
});
}
goBack() {
/**
* Navigate back to machines list
*/
goBack(): void {
this.router.navigate(['/machines/machines-list']);
}
}

View File

@ -21,6 +21,33 @@ export interface Machine {
last_maintenance_date?: string;
next_maintenance_date?: string;
firmware_version?: string;
// Created by fields (automatic from backend)
created_by?: number;
created_by_username?: string;
// ⭐ Option 3: Assigned Refillers
assigned_refillers?: AssignedRefiller[];
// UI helper for password toggle
showPassword?: boolean;
}
export interface AssignedRefiller {
id: number;
username: string;
email: string;
assigned_at: string;
}
export interface Refiller {
id: number;
user_id: string;
username: string;
email: string;
roles: string;
assigned_to?: number;
assigned_to_username?: string;
}
export interface VendingStateResponse {
@ -34,6 +61,7 @@ export interface VendingStateResponse {
export class MachineService {
private apiUrl = `${environment.apiUrl}/machines`;
private usersUrl = `${environment.apiUrl}/users`;
private backendBaseUrl = environment.apiUrl;
// 🔹 Loading state
private loadingSource = new BehaviorSubject<boolean>(false);
@ -45,9 +73,10 @@ export class MachineService {
constructor(private http: HttpClient) {}
// =====================
// 🔹 Shared state methods
// =====================
// =============================================
// SHARED STATE METHODS
// =============================================
setSelectedMachine(machine: Machine): void {
this.selectedMachineSource.next(machine);
}
@ -56,9 +85,10 @@ export class MachineService {
this.selectedMachineSource.next(null);
}
// =====================
// 🔹 CRUD methods
// =====================
// =============================================
// BASIC MACHINE CRUD OPERATIONS
// =============================================
getMachines(): Observable<Machine[]> {
this.loadingSource.next(true);
return this.http.get<Machine[]>(this.apiUrl).pipe(
@ -68,16 +98,25 @@ export class MachineService {
);
}
getUsers(): Observable<any[]> {
return this.http.get<any[]>(this.usersUrl).pipe(
tap((users) => console.log('Fetched users:', users)),
catchError(this.handleError<any[]>('getUsers', []))
getMachineById(id: number): Observable<Machine> {
return this.http.get<Machine>(`${this.apiUrl}/${id}`).pipe(
tap((m) => console.log('Fetched machine by id:', m)),
catchError(this.handleError<Machine>('getMachineById', {} as Machine))
);
}
addMachine(machine: Machine): Observable<Machine> {
this.loadingSource.next(true);
return this.http.post<Machine>(this.apiUrl, machine).pipe(
// Don't send created_by in the request body
// Backend automatically extracts it from JWT token
const payload = { ...machine };
delete payload.created_by;
delete payload.created_by_username;
delete payload.assigned_refillers;
delete payload.showPassword;
return this.http.post<Machine>(this.apiUrl, payload).pipe(
tap((newMachine) => console.log('Added new machine:', newMachine)),
catchError((error) => {
console.error('Error adding machine:', error);
@ -89,34 +128,88 @@ export class MachineService {
}
updateMachine(id: number, machine: Machine): Observable<Machine> {
this.loadingSource.next(true);
return this.http.put<Machine>(`${this.apiUrl}/${id}`, machine).pipe(
tap((updated) => console.log('Updated machine:', updated)),
catchError(this.handleError<Machine>('updateMachine', {} as Machine)),
tap(() => this.loadingSource.next(false))
);
}
deleteMachine(id: number): Observable<any> {
this.loadingSource.next(true);
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
tap(() => console.log('Deleted machine with ID:', id)),
catchError(this.handleError<any>('deleteMachine')),
tap(() => this.loadingSource.next(false))
);
}
// =====================
// 🔹 Advanced methods
// =====================
getMachineById(id: number): Observable<Machine> {
return this.http.get<Machine>(`${this.apiUrl}/${id}`).pipe(
tap((m) => console.log('Fetched machine by id:', m)),
catchError(this.handleError<Machine>('getMachineById', {} as Machine))
this.loadingSource.next(true);
// Don't send created_by or assigned_refillers in updates
const payload = { ...machine };
delete payload.created_by;
delete payload.created_by_username;
delete payload.assigned_refillers;
delete payload.showPassword;
return this.http.put<Machine>(`${this.apiUrl}/${id}`, payload).pipe(
tap((updated) => console.log('Updated machine:', updated)),
catchError(this.handleError<Machine>('updateMachine', {} as Machine)),
tap(() => this.loadingSource.next(false))
);
}
deleteMachine(id: number): Observable<any> {
this.loadingSource.next(true);
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
tap(() => console.log('Deleted machine with ID:', id)),
catchError(this.handleError<any>('deleteMachine')),
tap(() => this.loadingSource.next(false))
);
}
// =============================================
// USER RELATED (for dropdowns)
// =============================================
getUsers(): Observable<any[]> {
return this.http.get<any[]>(this.usersUrl).pipe(
tap((users) => console.log('Fetched users:', users)),
catchError(this.handleError<any[]>('getUsers', []))
);
}
// =============================================
// OPTION 3: MACHINE REFILLER ASSIGNMENTS
// =============================================
/**
* Get all Refillers assigned to a Machine
* @param machineId - The machine_id (e.g., "M001")
*/
getMachineRefillers(machineId: string): Observable<Refiller[]> {
return this.http.get<Refiller[]>(`${this.apiUrl}/${machineId}/refillers`).pipe(
tap((refillers) => console.log(`Fetched refillers for machine ${machineId}:`, refillers)),
catchError(this.handleError<Refiller[]>('getMachineRefillers', []))
);
}
/**
* Assign Refillers to a Machine
* @param machineId - The machine_id (e.g., "M001")
* @param refillerIds - Array of Refiller user IDs
*/
assignRefillersToMachine(machineId: string, refillerIds: number[]): Observable<any> {
return this.http.post(`${this.apiUrl}/${machineId}/refillers`, { refiller_ids: refillerIds }).pipe(
tap((response) => console.log(`Assigned refillers to machine ${machineId}:`, response)),
catchError(this.handleError<any>('assignRefillersToMachine'))
);
}
/**
* Get all available Refillers that can be assigned to a Machine
* Filters by Refillers assigned to the Machine's Client
*/
getAvailableRefillers(clientId?: number): Observable<Refiller[]> {
const url = clientId
? `${this.usersUrl}?roles=Refiller&assigned_to=${clientId}`
: `${this.usersUrl}?roles=Refiller`;
return this.http.get<Refiller[]>(url).pipe(
tap((refillers) => console.log('Fetched available refillers:', refillers)),
catchError(this.handleError<Refiller[]>('getAvailableRefillers', []))
);
}
// =============================================
// MACHINE STATUS OPERATIONS
// =============================================
updateMachineStatus(
id: number,
status: { operation_status?: string; connection_status?: string }
@ -127,9 +220,13 @@ deleteMachine(id: number): Observable<any> {
);
}
// =============================================
// VENDING STATE OPERATIONS
// =============================================
getMachineVendingState(machineId: string): Observable<VendingStateResponse> {
return this.http
.get<VendingStateResponse>(`${this.apiUrl}/${machineId}`)
.get<VendingStateResponse>(`${this.apiUrl}/${machineId}/vending-state`)
.pipe(
tap((state) => console.log('Fetched vending state:', state)),
catchError(
@ -149,6 +246,10 @@ deleteMachine(id: number): Observable<any> {
);
}
// =============================================
// MAINTENANCE OPERATIONS
// =============================================
getMachineMaintenanceHistory(machineId: string): Observable<any[]> {
return this.http
.get<any[]>(`${this.apiUrl}/${machineId}/maintenance-history`)
@ -165,13 +266,19 @@ deleteMachine(id: number): Observable<any> {
);
}
// =====================
// 🔹 Error handler
// =====================
// =============================================
// ERROR HANDLER
// =============================================
private handleError<T>(operation = 'operation', result?: T) {
return (error: HttpErrorResponse): Observable<T> => {
console.error(`${operation} failed:`, error.message);
if (error.error instanceof ErrorEvent) {
console.error('Client-side error:', error.error.message);
} else {
console.error(`Backend returned code ${error.status}, body:`, error.error);
}
return of(result as T);
};
}
}
}

View File

@ -47,6 +47,7 @@
<div>Operation</div>
<div>Connection</div>
<div>Password</div>
<div>Created By</div>
<div>Actions</div>
</div>
@ -72,6 +73,12 @@
</button>
</div>
<!-- Created By -->
<div class="flex items-center gap-1">
<mat-icon class="icon-size-4 text-gray-500" svgIcon="heroicons_outline:user"></mat-icon>
<span>{{ machine.created_by_username || 'N/A' }}</span>
</div>
<!-- Actions -->
<div class="flex gap-2">
<!-- Edit button - ONLY for admins -->
@ -145,6 +152,7 @@
</mat-option>
</mat-select>
</mat-form-field>
<!-- Branch -->
<mat-form-field class="w-full sm:w-1/2 pl-2">
<mat-label>Branch Name</mat-label>
@ -176,6 +184,13 @@
<input matInput formControlName="password" type="password" />
</mat-form-field>
<!-- Created By - Display only (read-only) -->
<mat-form-field class="w-full sm:w-1/2 pl-2" *ngIf="selectedMachine?.id !== null && selectedMachine?.created_by_username">
<mat-label>Created By</mat-label>
<input matInput [value]="selectedMachine.created_by_username" readonly />
<mat-icon matPrefix class="icon-size-5" svgIcon="heroicons_outline:user"></mat-icon>
</mat-form-field>
</div>
</div>

View File

@ -1,14 +1,16 @@
/* ============================================
CRITICAL RESPONSIVE FIXES FOR MACHINES LIST
MACHINES LIST - COMPLETE STYLES WITH DROPDOWN FIXES
============================================ */
/* ============================================
CRITICAL RESPONSIVE FIXES
============================================ */
/* Remove the min-width constraint that causes overflow */
.grid.min-w-max {
min-width: 0 !important;
width: 100%;
}
/* Ensure parent containers allow proper responsive behavior */
.flex.flex-auto.overflow-auto {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@ -18,6 +20,177 @@
min-width: 0;
}
/* ============================================
DROPDOWN (MAT-SELECT) FIXES - CRITICAL
============================================ */
/* Base mat-select styling */
.mat-mdc-form-field mat-select {
width: 100%;
display: block;
}
/* Dropdown trigger */
::ng-deep .mat-mdc-select-trigger {
display: flex;
align-items: center;
width: 100%;
min-height: 40px;
}
/* Selected value display */
::ng-deep .mat-mdc-select-value {
width: 100%;
max-width: 100%;
}
::ng-deep .mat-mdc-select-value-text {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Dropdown arrow */
::ng-deep .mat-mdc-select-arrow-wrapper {
height: 24px;
display: flex;
align-items: center;
padding-left: 8px;
}
::ng-deep .mat-mdc-select-arrow {
transition: transform 0.2s ease;
color: rgba(0, 0, 0, 0.54);
}
.dark ::ng-deep .mat-mdc-select-arrow {
color: rgba(255, 255, 255, 0.54);
}
::ng-deep .mat-mdc-select.mat-mdc-select-panel-open .mat-mdc-select-arrow {
transform: rotate(180deg);
}
/* Dropdown panel */
::ng-deep .mat-mdc-select-panel {
min-width: 100% !important;
max-width: 400px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
margin-top: 8px;
}
.dark ::ng-deep .mat-mdc-select-panel {
background-color: #2d3748;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
/* Dropdown options */
::ng-deep .mat-mdc-option {
padding: 12px 16px;
min-height: 48px;
line-height: 1.5;
transition: background-color 0.15s ease;
}
::ng-deep .mat-mdc-option:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.dark ::ng-deep .mat-mdc-option:hover {
background-color: rgba(255, 255, 255, 0.08);
}
::ng-deep .mat-mdc-option.mat-mdc-option-active,
::ng-deep .mat-mdc-option.mdc-list-item--selected {
background-color: rgba(25, 118, 210, 0.12);
font-weight: 500;
}
.dark ::ng-deep .mat-mdc-option.mat-mdc-option-active,
.dark ::ng-deep .mat-mdc-option.mdc-list-item--selected {
background-color: rgba(96, 165, 250, 0.2);
}
/* Placeholder styling */
::ng-deep .mat-mdc-select-placeholder {
color: rgba(0, 0, 0, 0.42);
}
.dark ::ng-deep .mat-mdc-select-placeholder {
color: rgba(255, 255, 255, 0.42);
}
/* ============================================
FORM FIELD LAYOUT
============================================ */
/* Form container */
.flex.flex-col.p-8.sm\:flex-row {
padding: 32px;
}
.flex.flex-auto.flex-wrap {
display: flex;
flex-wrap: wrap;
gap: 0;
width: 100%;
}
/* Form field base styling */
.mat-mdc-form-field {
width: 100%;
display: block;
}
/* Two-column layout on desktop */
.mat-mdc-form-field.w-full.sm\:w-1\/2 {
width: 100%;
margin-bottom: 16px;
}
@media (min-width: 640px) {
.mat-mdc-form-field.w-full.sm\:w-1\/2 {
width: calc(50% - 8px);
}
.mat-mdc-form-field.w-full.sm\:w-1\/2.pr-2 {
margin-right: 16px;
}
.mat-mdc-form-field.w-full.sm\:w-1\/2.pl-2 {
margin-left: 0;
}
}
/* Form field internals */
::ng-deep .mat-mdc-form-field-infix {
display: flex;
align-items: center;
min-height: 56px;
padding-top: 16px;
padding-bottom: 16px;
}
::ng-deep .mat-mdc-text-field-wrapper {
padding: 0;
padding-bottom: 0;
}
::ng-deep .mat-mdc-form-field-flex {
align-items: center;
}
::ng-deep .mat-mdc-form-field-subscript-wrapper {
margin-top: 6px;
}
::ng-deep .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-outline {
border-radius: 8px;
}
/* ============================================
INVENTORY GRID - BASE LAYOUT
============================================ */
@ -26,146 +199,115 @@
display: grid;
align-items: center;
width: 100%;
padding: 12px 16px;
gap: 12px;
padding: 16px 24px;
gap: 16px;
}
/* Desktop - All 10 columns visible */
.inventory-grid > div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Desktop - All 11 columns */
@media (min-width: 1400px) {
.inventory-grid {
grid-template-columns:
60px /* 1. ID */
140px /* 2. Machine ID */
180px /* 3. Model */
140px /* 4. Type */
180px /* 5. Client */
180px /* 6. Branch */
150px /* 7. Operation */
150px /* 8. Connection */
150px /* 9. Password */
200px; /* 10. Actions */
60px 140px 180px 140px 180px 180px 150px 150px 150px 150px 200px;
}
}
/* Large Laptop (1024px - 1399px) */
/* Large Laptop */
@media (min-width: 1024px) and (max-width: 1399px) {
.inventory-grid {
grid-template-columns:
50px /* 1. ID */
120px /* 2. Machine ID */
150px /* 3. Model */
110px /* 4. Type */
140px /* 5. Client */
140px /* 6. Branch */
130px /* 7. Operation */
130px /* 8. Connection */
120px /* 9. Password */
170px; /* 10. Actions */
50px 120px 150px 110px 140px 140px 130px 130px 120px 130px 170px;
padding: 12px 20px;
}
}
/* Tablet Landscape (768px - 1023px) - Hide Type, Connection, Password */
/* Tablet Landscape */
@media (min-width: 768px) and (max-width: 1023px) {
.inventory-grid {
grid-template-columns:
50px /* 1. ID */
120px /* 2. Machine ID */
150px /* 3. Model */
minmax(120px, 1fr) /* 5. Client */
130px /* 6. Branch */
120px /* 7. Operation */
160px; /* 10. Actions */
50px 120px 150px minmax(120px, 1fr) 130px 120px 160px;
padding: 12px 16px;
}
/* Hide Type (column 4) */
.inventory-grid > div:nth-child(4),
.inventory-grid > div:nth-child(8),
.inventory-grid > div:nth-child(9) {
.inventory-grid > div:nth-child(9),
.inventory-grid > div:nth-child(10) {
display: none !important;
}
}
/* Tablet Portrait (576px - 767px) - Show ID, Machine ID, Model, Actions */
/* Tablet Portrait */
@media (min-width: 576px) and (max-width: 767px) {
.inventory-grid {
grid-template-columns:
45px /* 1. ID */
minmax(100px, 1fr) /* 2. Machine ID */
140px /* 3. Model */
180px; /* 10. Actions */
padding: 10px 12px;
grid-template-columns: 45px minmax(100px, 1fr) 140px 180px;
padding: 12px 16px;
}
/* Hide columns 4-9 */
.inventory-grid > div:nth-child(4),
.inventory-grid > div:nth-child(5),
.inventory-grid > div:nth-child(6),
.inventory-grid > div:nth-child(7),
.inventory-grid > div:nth-child(8),
.inventory-grid > div:nth-child(9) {
.inventory-grid > div:nth-child(9),
.inventory-grid > div:nth-child(10) {
display: none !important;
}
/* Adjust action buttons */
.inventory-grid .flex.gap-2 {
flex-direction: row;
gap: 6px;
gap: 8px;
}
.inventory-grid button {
padding: 4px 8px;
font-size: 12px;
min-height: 32px;
padding: 6px 12px;
font-size: 13px;
min-height: 36px;
}
}
/* Mobile (< 576px) - Show only ID, Machine ID, Actions */
/* Mobile */
@media (max-width: 575px) {
.inventory-grid {
grid-template-columns:
40px /* 1. ID */
minmax(80px, 1fr) /* 2. Machine ID */
130px; /* 10. Actions */
padding: 8px 8px;
gap: 8px;
font-size: 12px;
grid-template-columns: 40px minmax(80px, 1fr) 130px;
padding: 12px 12px;
gap: 12px;
font-size: 13px;
}
/* Hide columns 3-9 */
.inventory-grid > div:nth-child(3),
.inventory-grid > div:nth-child(4),
.inventory-grid > div:nth-child(5),
.inventory-grid > div:nth-child(6),
.inventory-grid > div:nth-child(7),
.inventory-grid > div:nth-child(8),
.inventory-grid > div:nth-child(9) {
.inventory-grid > div:nth-child(9),
.inventory-grid > div:nth-child(10) {
display: none !important;
}
/* Stack action buttons vertically */
.inventory-grid .flex.gap-2 {
flex-direction: column;
gap: 6px;
gap: 8px;
width: 100%;
}
.inventory-grid button {
width: 100%;
font-size: 11px;
padding: 6px 4px;
min-height: 36px;
font-size: 12px;
padding: 8px 6px;
min-height: 40px;
min-width: auto !important;
}
.inventory-grid button mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
/* ============================================
HEADER ROW SPECIFIC STYLING
HEADER ROW STYLING
============================================ */
.inventory-grid.text-secondary {
@ -173,6 +315,7 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 14px;
border-bottom: 2px solid #e5e7eb;
position: sticky;
top: 0;
@ -184,13 +327,6 @@
border-bottom-color: rgba(255, 255, 255, 0.1);
}
@media (max-width: 575px) {
.inventory-grid.text-secondary {
font-size: 10px;
padding: 8px 8px;
}
}
/* ============================================
DATA ROW STYLING
============================================ */
@ -201,36 +337,62 @@
}
.inventory-grid:not(.text-secondary):hover {
background-color: #f3f4f6;
background-color: #f8f9fa;
}
.dark .inventory-grid:not(.text-secondary) {
border-bottom-color: rgba(255, 255, 255, 0.05);
}
.dark .inventory-grid:not(.text-secondary):hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* Text overflow handling */
.inventory-grid > div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Actions column should wrap on mobile */
.inventory-grid > div:last-child {
white-space: normal;
}
/* ============================================
HEADER SECTION (Top bar with search & add button)
CREATED BY COLUMN
============================================ */
.inventory-grid > div:nth-child(10) {
display: flex;
align-items: center;
gap: 8px;
}
.inventory-grid > div:nth-child(10) mat-icon {
color: #6b7280;
font-size: 18px;
width: 18px;
height: 18px;
}
.inventory-grid > div:nth-child(10) span {
font-size: 14px;
color: #374151;
}
.dark .inventory-grid > div:nth-child(10) mat-icon {
color: #9ca3af;
}
.dark .inventory-grid > div:nth-child(10) span {
color: #d1d5db;
}
/* ============================================
HEADER SECTION
============================================ */
.relative.flex.flex-0.flex-col.border-b {
padding: 16px;
padding: 24px 32px;
}
@media (max-width: 767px) {
.relative.flex.flex-0.flex-col.border-b {
padding: 16px 12px;
padding: 20px 16px;
}
.text-4xl.font-extrabold.tracking-tight {
@ -238,7 +400,6 @@
line-height: 2.25rem;
}
/* Search and button container */
.mt-6.flex.shrink-0.items-center {
flex-direction: column;
align-items: stretch !important;
@ -247,13 +408,11 @@
gap: 12px;
}
/* Search field takes full width */
mat-form-field.fuse-mat-dense.fuse-mat-rounded.min-w-64 {
min-width: 0 !important;
width: 100%;
}
/* Add button takes full width */
.mt-6.flex.shrink-0.items-center > button.ml-4 {
margin-left: 0 !important;
width: 100%;
@ -261,106 +420,68 @@
}
}
@media (max-width: 575px) {
.relative.flex.flex-0.flex-col.border-b {
padding: 12px 8px;
}
.text-4xl.font-extrabold.tracking-tight {
font-size: 1.5rem;
line-height: 2rem;
}
}
/* ============================================
EXPANDED FORM (Create/Edit Form)
============================================ */
/* Form container */
.overflow-hidden.shadow-lg {
margin: 0;
border-radius: 0;
}
/* Form fields container */
.flex.flex-col.p-8.sm\:flex-row {
padding: 24px;
}
@media (max-width: 767px) {
.flex.flex-col.p-8.sm\:flex-row {
padding: 16px;
}
/* Make all form fields full width */
.mat-form-field.w-full.sm\:w-1\/2 {
width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
margin-bottom: 12px;
}
.mat-form-field.w-full.sm\:w-1\/2.pr-2,
.mat-form-field.w-full.sm\:w-1\/2.pl-2 {
padding: 0 !important;
}
}
/* ============================================
FORM FOOTER BUTTONS (Delete/Create/Update)
FORM FOOTER BUTTONS
============================================ */
.flex.w-full.items-center.justify-between.border-t {
padding: 16px 24px;
padding: 20px 32px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
gap: 16px;
border-top: 1px solid #e5e7eb;
}
/* Delete button */
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
margin-left: 0 !important;
min-height: 40px;
min-height: 44px;
padding: 0 24px;
transition: all 0.2s ease;
}
.flex.w-full.items-center.justify-between.border-t > button.-ml-4:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(220, 38, 38, 0.3);
}
/* Right side container (flash message + action button) */
.flex.w-full.items-center.justify-between.border-t > .flex.items-center {
display: flex;
gap: 12px;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
/* Create/Update button */
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
min-height: 40px;
padding: 0 24px;
min-height: 44px;
padding: 0 32px;
transition: all 0.2s ease;
}
.flex.w-full.items-center.justify-between.border-t button[color="primary"]:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(25, 118, 210, 0.3);
}
@media (max-width: 767px) {
.flex.w-full.items-center.justify-between.border-t {
flex-direction: column;
align-items: stretch !important;
padding: 16px;
gap: 12px;
padding: 20px 24px;
gap: 16px;
}
/* Delete button full width */
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
width: 100%;
order: 2;
min-height: 48px;
}
/* Empty div placeholder - hide on mobile */
.flex.w-full.items-center.justify-between.border-t > div:empty {
display: none;
}
/* Right side container full width */
.flex.w-full.items-center.justify-between.border-t > .flex.items-center {
width: 100%;
order: 1;
@ -368,29 +489,32 @@
gap: 12px;
}
/* Flash message centered */
.flex.w-full.items-center.justify-between.border-t .mr-4.flex.items-center {
margin-right: 0 !important;
width: 100%;
justify-content: center;
}
/* Create/Update button full width */
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
width: 100%;
min-height: 48px;
font-size: 15px;
}
}
@media (max-width: 575px) {
.flex.w-full.items-center.justify-between.border-t {
padding: 12px 8px;
.mat-mdc-form-field.w-full.sm\:w-1\/2 {
width: 100% !important;
padding: 0 !important;
margin: 0 0 16px 0 !important;
}
.flex.w-full.items-center.justify-between.border-t button {
min-height: 52px;
font-size: 14px;
.mat-mdc-form-field.w-full.sm\:w-1\/2.pr-2,
.mat-mdc-form-field.w-full.sm\:w-1\/2.pl-2 {
padding: 0 !important;
margin: 0 0 16px 0 !important;
}
::ng-deep .mat-mdc-select-panel {
max-width: calc(100vw - 32px);
}
}
@ -400,16 +524,83 @@
.mr-4.flex.items-center {
transition: opacity 0.3s ease;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.mr-4.flex.items-center mat-icon {
margin-right: 8px;
}
@media (max-width: 575px) {
.mr-4.flex.items-center {
font-size: 13px;
}
.mr-4.flex.items-center:has(mat-icon.text-green-500) {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.mr-4.flex.items-center:has(mat-icon.text-red-500) {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.dark .mr-4.flex.items-center:has(mat-icon.text-green-500) {
background-color: rgba(72, 187, 120, 0.2);
color: #68d391;
border-color: rgba(72, 187, 120, 0.3);
}
.dark .mr-4.flex.items-center:has(mat-icon.text-red-500) {
background-color: rgba(245, 101, 101, 0.2);
color: #fc8181;
border-color: rgba(245, 101, 101, 0.3);
}
/* ============================================
PASSWORD FIELD
============================================ */
.flex.items-center.gap-1 {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
}
.flex.items-center.gap-1 span {
font-family: 'Courier New', monospace;
font-size: 14px;
}
.flex.items-center.gap-1 button {
padding: 6px;
min-width: auto;
border-radius: 6px;
transition: all 0.2s ease;
}
.flex.items-center.gap-1 button:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.dark .flex.items-center.gap-1 button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
/* ============================================
ACTION BUTTONS
============================================ */
.inventory-grid .flex.gap-2 button {
transition: all 0.2s ease;
border-radius: 6px;
}
.inventory-grid .flex.gap-2 button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* ============================================
@ -418,174 +609,102 @@
mat-paginator {
border-top: 1px solid #e5e7eb;
border-radius: 8px;
}
@media (max-width: 767px) {
mat-paginator {
font-size: 12px;
font-size: 13px;
}
.mat-mdc-paginator-container {
padding: 8px !important;
padding: 12px !important;
flex-wrap: wrap;
gap: 8px;
gap: 12px;
}
}
/* ============================================
EXPANDED FORM
============================================ */
.overflow-hidden.shadow-lg {
margin: 0;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
}
@media (max-width: 575px) {
.flex.flex-col.p-8.sm\:flex-row {
padding: 20px;
}
.mat-mdc-paginator-page-size {
margin: 0 !important;
::ng-deep .mat-mdc-form-field-infix {
min-height: 52px;
}
}
/* ============================================
PASSWORD FIELD WITH TOGGLE
============================================ */
.flex.items-center.gap-1 {
display: flex;
align-items: center;
gap: 4px;
min-width: 100px;
}
.flex.items-center.gap-1 button {
padding: 4px;
min-width: auto;
}
@media (max-width: 767px) {
.flex.items-center.gap-1 button mat-icon {
font-size: 18px;
::ng-deep .mat-mdc-option {
padding: 10px 12px;
min-height: 44px;
font-size: 14px;
}
}
/* ============================================
STATUS BADGES & INDICATORS
============================================ */
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.status-badge.active {
background-color: #dcfce7;
color: #166534;
}
.status-badge.inactive {
background-color: #fee2e2;
color: #991b1b;
}
.status-badge.maintenance {
background-color: #fef3c7;
color: #92400e;
}
/* ============================================
MATERIAL FORM FIELD OVERRIDES
============================================ */
.mat-mdc-form-field {
width: 100%;
}
::ng-deep .mat-mdc-form-field-subscript-wrapper {
margin-top: 4px;
}
::ng-deep .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-outline {
border-radius: 8px;
}
/* ============================================
EMPTY STATE
============================================ */
.border-t.p-8.text-center {
padding: 48px 24px;
}
@media (max-width: 575px) {
.border-t.p-8.text-center {
padding: 32px 16px;
font-size: 1.5rem;
}
padding: 60px 32px;
text-align: center;
color: #6c757d;
}
/* ============================================
DARK MODE SUPPORT
DARK MODE
============================================ */
.dark .flex.border-b {
border-color: rgba(255, 255, 255, 0.1);
}
.dark .border-b {
border-color: rgba(255, 255, 255, 0.1);
}
.dark .flex.border-b,
.dark .border-b,
.dark .border-t {
border-color: rgba(255, 255, 255, 0.1);
}
.dark .overflow-hidden.shadow-lg {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
/* ============================================
UTILITY CLASSES
FOCUS STATES
============================================ */
@media (max-width: 767px) {
.sm\:absolute {
position: relative !important;
}
.sm\:inset-0 {
inset: auto !important;
}
}
/* Smooth transitions */
button {
transition: all 0.2s ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
button:active:not(:disabled) {
transform: translateY(0);
}
/* Focus states for accessibility */
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
border-radius: 6px;
}
::ng-deep .mat-mdc-select:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* ============================================
PRINT STYLES
CLIENT VIEW NOTICE
============================================ */
@media print {
.inventory-grid {
grid-template-columns: auto auto auto auto auto auto auto auto auto auto;
font-size: 10px;
padding: 4px;
}
.inventory-grid button {
display: none;
}
mat-paginator {
display: none;
}
p.mt-2.text-sm.text-gray-600 {
padding: 8px 16px;
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
border-radius: 4px;
margin-top: 12px;
}
.dark p.mt-2.text-sm.text-gray-600 {
background-color: rgba(251, 191, 36, 0.1);
border-left-color: #fbbf24;
color: #fcd34d;
}

View File

@ -1 +1,335 @@
<p>brand works!</p>
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
<!-- Header -->
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
<!-- Loader -->
@if (isLoading) {
<div class="absolute inset-x-0 bottom-0">
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
</div>
}
<!-- Title -->
<div class="text-4xl font-extrabold tracking-tight">Brand Management</div>
<!-- Actions -->
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
<!-- Search -->
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
[placeholder]="'Search brands'" />
</mat-form-field>
<!-- Add brand button -->
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createBrand()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2 mr-1">Add Brand</span>
</button>
</div>
</div>
<!-- Main -->
<div class="flex flex-auto overflow-hidden">
<!-- Brands list -->
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
@if (brands$ | async; as brands) {
@if (brands.length > 0 || selectedBrand) {
<div class="grid">
<!-- Header -->
<div class="brand-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort matSortDisableClear>
<div [mat-sort-header]="'brand_id'">Brand ID</div>
<div [mat-sort-header]="'name'">Name</div>
<div class="hidden md:block" [mat-sort-header]="'branch_id'">Branch ID</div>
<div class="hidden lg:block" [mat-sort-header]="'branch_name'">Branch Name</div>
<div class="hidden sm:block">Image</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block">Created By</div>
<div class="text-center">Actions</div>
</div>
<!-- New Brand Form Row -->
@if (selectedBrand && !selectedBrand.id) {
<div class="brand-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
<div class="truncate">
<span class="text-xs font-medium text-blue-600">Auto-generated</span>
</div>
<div class="truncate font-medium text-blue-600">New Brand</div>
<div class="hidden truncate md:block text-blue-600 text-sm">-</div>
<div class="hidden truncate lg:block text-blue-600 text-sm">-</div>
<div class="hidden sm:block text-blue-600 text-sm">-</div>
<!-- ⭐ NEW: Created By (Auto) -->
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>Auto</span>
</div>
<div class="flex items-center justify-center gap-2">
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
</button>
</div>
</div>
<!-- New Brand Form Details -->
<div class="grid">
<ng-container
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedBrand }"></ng-container>
</div>
}
<!-- Existing Brands -->
@for (brand of brands; track trackByFn($index, brand)) {
<div class="brand-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
<!-- Brand ID -->
<div class="truncate">
<span class="text-xs font-mono text-blue-600 font-semibold">{{ brand.brand_id }}</span>
</div>
<!-- Name -->
<div class="truncate">
<div class="font-semibold text-gray-900">{{ brand.name }}</div>
</div>
<!-- Branch ID -->
<div class="hidden truncate md:block">
<div class="text-sm text-gray-600">{{ brand.branch_id }}</div>
</div>
<!-- Branch Name -->
<div class="hidden truncate lg:block">
<div class="text-sm text-gray-600">{{ brand.branch_name }}</div>
</div>
<!-- Image -->
<div class="hidden sm:flex items-center">
@if (brand.image) {
<img [src]="getFullImageUrl(brand.image)" [alt]="brand.name"
class="h-10 w-10 rounded object-cover border">
} @else {
<div class="h-10 w-10 rounded bg-gray-100 flex items-center justify-center border">
<mat-icon class="icon-size-5 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
</div>
}
</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block flex items-center gap-1">
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>{{ brand.created_by_username || 'N/A' }}</span>
</div>
<!-- Actions -->
<div class="flex items-center justify-center gap-1">
<button mat-icon-button (click)="toggleDetails(brand.id)"
[matTooltip]="selectedBrand?.id === brand.id ? 'Hide Details' : 'View Details'">
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
selectedBrand?.id === brand.id
? 'heroicons_outline:chevron-up'
: 'heroicons_outline:chevron-down'
"></mat-icon>
</button>
<button mat-icon-button (click)="editBrand(brand)" matTooltip="Edit">
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
</button>
<button mat-icon-button (click)="deleteBrand(brand)" matTooltip="Delete">
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
</button>
</div>
</div>
<!-- Brand Details -->
@if (selectedBrand?.id === brand.id) {
<div class="grid">
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: brand }"></ng-container>
</div>
}
}
</div>
<mat-paginator
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
[showFirstLastButtons]="true"></mat-paginator>
} @else {
<!-- Empty State -->
<div class="flex flex-col items-center justify-center p-16">
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:tag'"></mat-icon>
</div>
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No brands found</h3>
<p class="text-sm text-gray-500 mb-6">Get started by creating your first brand</p>
<button mat-flat-button [color]="'primary'" (click)="createBrand()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2">Add Brand</span>
</button>
</div>
}
}
<!-- Row Details Template -->
<ng-template #rowDetailsTemplate let-brand>
<div class="overflow-hidden bg-white border-b">
<div class="flex">
<form class="flex w-full flex-col" [formGroup]="selectedBrandForm">
<div class="p-8">
<!-- Header -->
<div class="mb-6 pb-6 border-b">
<h2 class="text-xl font-semibold text-gray-900">
{{ brand.id ? 'Edit Brand' : 'Create New Brand' }}
</h2>
@if (brand.brand_id) {
<p class="text-sm text-gray-500 mt-1">Brand ID: <span class="font-mono text-blue-600">{{ brand.brand_id }}</span></p>
} @else {
<p class="text-sm text-gray-500 mt-1">Brand ID will be auto-generated from name (first 3 letters + 4-digit sequence)</p>
}
</div>
<!-- Form Fields -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Brand Name -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Brand Name</mat-label>
<input matInput [formControlName]="'name'" placeholder="e.g., Coca-Cola"
(input)="onNameChange($event)" />
<mat-error *ngIf="selectedBrandForm.get('name')?.hasError('required')">
Name is required
</mat-error>
<mat-error *ngIf="selectedBrandForm.get('name')?.hasError('minlength')">
Minimum 2 characters required
</mat-error>
<mat-hint>ID will be: {{ previewBrandId }}</mat-hint>
</mat-form-field>
<!-- Branch Selection -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch</mat-label>
<mat-select [formControlName]="'branch_id'" (selectionChange)="onBranchChange($event)">
<mat-option *ngFor="let branch of branches" [value]="branch.branch_id">
{{ branch.name }} ({{ branch.branch_id }})
</mat-option>
</mat-select>
<mat-error *ngIf="selectedBrandForm.get('branch_id')?.hasError('required')">
Branch is required
</mat-error>
<mat-hint>Branch will be auto-filled</mat-hint>
</mat-form-field>
<!-- Auto-filled Branch ID (readonly) -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch ID</mat-label>
<input matInput [value]="selectedBranchId" readonly />
<mat-hint>Auto-filled from branch selection</mat-hint>
</mat-form-field>
<!-- Auto-filled Branch Name (readonly) -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch Name</mat-label>
<input matInput [value]="selectedBranchName" readonly />
<mat-hint>Auto-filled from branch selection</mat-hint>
</mat-form-field>
<!-- ⭐ NEW: Created By Field (Read-only) -->
@if (brand.id && brand.created_by_username) {
<mat-form-field class="w-full" appearance="outline">
<mat-label>Created By</mat-label>
<input
matInput
[value]="brand.created_by_username"
readonly
/>
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
</mat-form-field>
}
<!-- Brand Image Upload -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">Brand Image (Optional)</label>
<div class="flex items-center gap-4">
<!-- Image Preview -->
<div class="flex-shrink-0">
@if (imagePreview || brand.image) {
<img [src]="imagePreview || getFullImageUrl(brand.image)" [alt]="brand.name"
class="h-24 w-24 rounded-lg object-cover border-2 border-gray-200">
} @else {
<div class="h-24 w-24 rounded-lg bg-gray-100 flex items-center justify-center border-2 border-dashed border-gray-300">
<mat-icon class="icon-size-8 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
</div>
}
</div>
<!-- Upload Button -->
<div class="flex-1">
<label class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<mat-icon class="icon-size-5 mr-2" [svgIcon]="'heroicons_outline:cloud-arrow-up'"></mat-icon>
Upload Image
<input type="file" accept="image/*" class="hidden" (change)="onImageSelected($event)">
</label>
<p class="mt-2 text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
@if (imagePreview || brand.image) {
<button type="button" mat-button color="warn" class="mt-2" (click)="removeImage()">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
Remove Image
</button>
}
</div>
</div>
</div>
</div>
<!-- Metadata -->
@if (brand.id) {
<div class="mt-6 pt-6 border-t">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Created:</span>
<span class="ml-2 text-gray-900">{{ brand.created_at | date:'medium' }}</span>
</div>
@if (brand.updated_at && brand.updated_at !== brand.created_at) {
<div>
<span class="text-gray-500">Last Updated:</span>
<span class="ml-2 text-gray-900">{{ brand.updated_at | date:'medium' }}</span>
</div>
}
</div>
</div>
}
</div>
<!-- Action Buttons -->
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
<button mat-button [color]="'warn'" (click)="deleteSelectedBrand()"
type="button" [disabled]="!brand.id">
Delete
</button>
<div class="flex items-center gap-3">
@if (flashMessage) {
<div class="flex items-center text-sm">
@if (flashMessage === 'success') {
<mat-icon class="icon-size-5 text-green-600 mr-2"
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
<span class="text-green-600">{{ brand.id ? 'Updated' : 'Created' }} successfully</span>
}
@if (flashMessage === 'error') {
<mat-icon class="icon-size-5 text-red-600 mr-2"
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
<span class="text-red-600">Error occurred</span>
}
</div>
}
<button mat-stroked-button (click)="closeDetails()" type="button">
Cancel
</button>
<button mat-flat-button [color]="'primary'" (click)="updateSelectedBrand()"
type="button" [disabled]="selectedBrandForm.invalid || isLoading">
@if (isLoading) {
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
}
<span>{{ brand.id ? 'Update' : 'Create' }}</span>
</button>
</div>
</div>
</form>
</div>
</div>
</ng-template>
</div>
</div>
</div>

View File

@ -0,0 +1,397 @@
/* Brand grid layout - NOW 7 COLUMNS WITH CREATED_BY */
.brand-grid {
// Brand ID, Name, Branch ID, Branch Name, Image, Created By, Actions (7 columns)
grid-template-columns: 120px 1fr 120px 150px 80px 150px 120px;
@media (max-width: 1279px) { // xl breakpoint - hide Created By
grid-template-columns: 120px 1fr 120px 150px 80px 120px;
}
@media (max-width: 1023px) { // lg breakpoint - hide branch name
grid-template-columns: 120px 1fr 120px 80px 120px;
}
@media (max-width: 767px) { // md breakpoint - hide branch ID
grid-template-columns: 120px 1fr 80px 120px;
}
@media (max-width: 639px) { // sm breakpoint - hide image
grid-template-columns: 120px 1fr 80px;
}
}
/* Created By Column Styling */
.brand-grid > div:nth-child(6) {
display: flex;
align-items: center;
gap: 6px;
}
.brand-grid > div:nth-child(6) mat-icon {
color: #6b7280;
font-size: 16px;
width: 16px;
height: 16px;
}
.brand-grid > div:nth-child(6) span {
font-size: 13px;
color: #374151;
}
.dark .brand-grid > div:nth-child(6) mat-icon {
color: #9ca3af;
}
.dark .brand-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-8 {
width: 32px;
height: 32px;
font-size: 32px;
}
.icon-size-16 {
width: 64px;
height: 64px;
font-size: 64px;
}
/* Brand image styling */
.brand-image {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.brand-image-placeholder {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
/* Image preview styling */
.image-preview-container {
position: relative;
img {
border: 2px solid #e5e7eb;
transition: border-color 0.2s ease;
&:hover {
border-color: #3b82f6;
}
}
}
/* Upload button styling */
.upload-button {
transition: all 0.2s ease;
&:hover {
background-color: #f9fafb;
border-color: #3b82f6;
}
}
/* 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;
}
}
/* Brand row hover effect */
.brand-grid > div:not(.sticky):not(.bg-blue-50) {
transition: background-color 0.15s ease;
}
/* 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) {
.brand-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);
}
.brand-grid > div:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.brand-image,
.brand-image-placeholder {
border-color: #374151;
background-color: #1f2937;
}
}
/* 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;
}
/* Brand 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;
}
/* 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 brand 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;
}
/* Image upload area */
.image-upload-area {
border: 2px dashed #d1d5db;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.2s ease;
&:hover {
border-color: #3b82f6;
background-color: #eff6ff;
}
}
/* Preview badge style */
.preview-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
background-color: #eff6ff;
color: #1e40af;
border-radius: 6px;
font-size: 12px;
font-family: 'Courier New', monospace;
font-weight: 600;
}

View File

@ -1,12 +1,463 @@
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 { MatSelectModule } from '@angular/material/select';
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 } from '@angular/material/dialog';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, switchMap, takeUntil } from 'rxjs';
import { BrandService, Brand } from './brand.service';
import { BranchService, Branch } from '../../company/branch-list/branch.service';
@Component({
selector: 'app-brand',
standalone: true,
imports: [],
imports: [
CommonModule,
ReactiveFormsModule,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatProgressBarModule,
MatDialogModule,
MatSnackBarModule
],
templateUrl: './brand.component.html',
styleUrl: './brand.component.scss'
})
export class BrandComponent {
export class BrandComponent implements OnInit, OnDestroy {
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
}
// Observables
brands$: Observable<Brand[]>;
private brandsSubject = new BehaviorSubject<Brand[]>([]);
private destroy$ = new Subject<void>();
// Form controls
searchInputControl = new FormControl('');
selectedBrandForm!: FormGroup;
// State management
selectedBrand: Brand | null = null;
isLoading = false;
flashMessage: 'success' | 'error' | null = null;
// Pagination
pagination = {
length: 0,
page: 0,
size: 10
};
// Branches for dropdown
branches: Branch[] = [];
// Auto-fill fields
selectedBranchId = '';
selectedBranchName = '';
previewBrandId = 'XXX0001';
// Image handling
imagePreview: string | null = null;
selectedImageFile: File | null = null;
constructor(
private fb: FormBuilder,
private brandService: BrandService,
private branchService: BranchService,
private snackBar: MatSnackBar
) {
this.brands$ = this.brandsSubject.asObservable();
this.initializeForm();
}
ngOnInit(): void {
this.loadBranches();
this.loadBrands();
this.setupSearch();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Initialize the brand form
*/
private initializeForm(): void {
this.selectedBrandForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
branch_id: ['', [Validators.required]]
});
}
/**
* Setup search functionality with debounce
*/
private setupSearch(): void {
this.searchInputControl.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe((searchTerm) => {
this.filterBrands(searchTerm || '');
});
}
/**
* Load all branches for dropdown
*/
loadBranches(): void {
this.branchService.getAllBranches()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (branches) => {
this.branches = branches;
console.log('Branches loaded:', branches.length);
},
error: (error) => {
console.error('Error loading branches:', error);
this.showSnackBar('Failed to load branches', 'error');
}
});
}
/**
* Load all brands from the backend
*/
loadBrands(): void {
this.isLoading = true;
this.brandService.getAllBrands()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (brands) => {
this.brandsSubject.next(brands);
this.pagination.length = brands.length;
this.isLoading = false;
},
error: (error) => {
console.error('Error loading brands:', error);
this.showSnackBar('Failed to load brands', 'error');
this.isLoading = false;
}
});
}
/**
* Filter brands based on search term
*/
private filterBrands(searchTerm: string): void {
this.brandService.getAllBrands()
.pipe(
map(brands => {
if (!searchTerm) {
return brands;
}
const term = searchTerm.toLowerCase();
return brands.filter(brand =>
brand.name.toLowerCase().includes(term) ||
brand.brand_id.toLowerCase().includes(term) ||
brand.branch_name.toLowerCase().includes(term)
);
}),
takeUntil(this.destroy$)
)
.subscribe(filtered => {
this.brandsSubject.next(filtered);
this.pagination.length = filtered.length;
});
}
/**
* Preview brand ID generation based on name
*/
onNameChange(event: any): void {
const name = event.target.value;
if (name) {
this.previewBrandId = this.generateBrandIdPreview(name);
} else {
this.previewBrandId = 'XXX0001';
}
}
/**
* Generate brand ID preview (frontend simulation)
*/
private generateBrandIdPreview(name: string): string {
const lettersOnly = name.replace(/[^a-zA-Z]/g, '').toUpperCase();
const prefix = lettersOnly.substring(0, 3).padEnd(3, 'X');
return `${prefix}0001`;
}
/**
* Handle branch selection change
*/
onBranchChange(event: any): void {
const branchId = event.value;
const branch = this.branches.find(b => b.branch_id === branchId);
if (branch) {
this.selectedBranchId = branch.branch_id;
this.selectedBranchName = branch.name;
} else {
this.selectedBranchId = '';
this.selectedBranchName = '';
}
}
/**
* Handle image selection
*/
onImageSelected(event: any): void {
const file = event.target.files[0];
if (file) {
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
this.showSnackBar('Image size must be less than 5MB', 'error');
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
this.showSnackBar('Please select an image file', 'error');
return;
}
this.selectedImageFile = file;
// Create preview
const reader = new FileReader();
reader.onload = (e: any) => {
this.imagePreview = e.target.result;
};
reader.readAsDataURL(file);
}
}
/**
* Remove selected image
*/
removeImage(): void {
this.imagePreview = null;
this.selectedImageFile = null;
if (this.selectedBrand?.id) {
this.selectedBrand.image = null;
}
}
/**
* Get full image URL
*/
getFullImageUrl(imagePath: string | null): string {
if (!imagePath) return '';
if (imagePath.startsWith('http')) return imagePath;
return `http://localhost:5000${imagePath}`;
}
/**
* Create new brand - open form
*/
createBrand(): void {
this.selectedBrand = {
id: 0,
brand_id: '',
name: '',
branch_id: '',
branch_name: '',
image: null,
created_at: '',
updated_at: ''
};
this.selectedBrandForm.reset();
this.selectedBranchId = '';
this.selectedBranchName = '';
this.previewBrandId = 'XXX0001';
this.imagePreview = null;
this.selectedImageFile = null;
this.flashMessage = null;
}
/**
* Edit existing brand
*/
editBrand(brand: Brand): void {
this.selectedBrand = { ...brand };
this.selectedBrandForm.patchValue({
name: brand.name,
branch_id: brand.branch_id
});
this.selectedBranchId = brand.branch_id;
this.selectedBranchName = brand.branch_name;
this.previewBrandId = brand.brand_id;
this.imagePreview = brand.image ? this.getFullImageUrl(brand.image) : null;
this.selectedImageFile = null;
this.flashMessage = null;
}
/**
* Toggle brand details view
*/
toggleDetails(brandId: number): void {
if (this.selectedBrand?.id === brandId) {
this.closeDetails();
} else {
const brand = this.brandsSubject.value.find(b => b.id === brandId);
if (brand) {
this.editBrand(brand);
}
}
}
/**
* Close details panel
*/
closeDetails(): void {
this.selectedBrand = null;
this.selectedBrandForm.reset();
this.selectedBranchId = '';
this.selectedBranchName = '';
this.previewBrandId = 'XXX0001';
this.imagePreview = null;
this.selectedImageFile = null;
this.flashMessage = null;
}
/**
* Update or create brand
*/
updateSelectedBrand(): void {
if (this.selectedBrandForm.invalid) {
this.selectedBrandForm.markAllAsTouched();
return;
}
this.isLoading = true;
// Prepare form data
const formData = new FormData();
formData.append('name', this.selectedBrandForm.get('name')?.value);
formData.append('branch_id', this.selectedBrandForm.get('branch_id')?.value);
// Add image if selected
if (this.selectedImageFile) {
formData.append('image', this.selectedImageFile);
}
const operation = this.selectedBrand?.id
? this.brandService.updateBrand(this.selectedBrand.id, formData)
: this.brandService.createBrand(formData);
operation
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (brand) => {
this.flashMessage = 'success';
this.isLoading = false;
this.showSnackBar(
this.selectedBrand?.id ? 'Brand updated successfully' : 'Brand created successfully',
'success'
);
setTimeout(() => {
this.closeDetails();
this.loadBrands();
}, 1500);
},
error: (error) => {
console.error('Error saving brand:', error);
this.flashMessage = 'error';
this.isLoading = false;
this.showSnackBar(
error.error?.error || 'Failed to save brand',
'error'
);
}
});
}
/**
* Delete selected brand
*/
deleteSelectedBrand(): void {
if (!this.selectedBrand?.id) {
return;
}
if (!confirm(`Are you sure you want to delete brand "${this.selectedBrand.name}"?`)) {
return;
}
this.deleteBrand(this.selectedBrand);
}
/**
* Delete brand
*/
deleteBrand(brand: Brand): void {
if (!confirm(`Are you sure you want to delete brand "${brand.name}"?`)) {
return;
}
this.isLoading = true;
this.brandService.deleteBrand(brand.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.showSnackBar('Brand deleted successfully', 'success');
this.closeDetails();
this.loadBrands();
},
error: (error) => {
console.error('Error deleting brand:', error);
this.showSnackBar(
error.error?.error || 'Failed to delete brand',
'error'
);
this.isLoading = false;
}
});
}
/**
* Track by function for ngFor
*/
trackByFn(index: number, item: Brand): 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'
});
}
}

View File

@ -0,0 +1,149 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from '../../../../../../environments/environment';
export interface Brand {
id: number;
brand_id: string;
name: string;
branch_id: string;
branch_name: string;
image: string | null;
created_at: string;
updated_at: string;
// ⭐ NEW: Created by fields (automatic from backend)
created_by?: number;
created_by_username?: string;
}
@Injectable({
providedIn: 'root'
})
export class BrandService {
private apiUrl = `${environment.apiUrl}/brands`;
constructor(private http: HttpClient) {}
/**
* Get authorization headers
*/
private getHeaders(): HttpHeaders {
const token = localStorage.getItem('accessToken');
return new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
});
}
/**
* Get all brands
*/
getAllBrands(): Observable<Brand[]> {
return this.http.get<Brand[]>(this.apiUrl, { headers: this.getHeaders() })
.pipe(
catchError(this.handleError)
);
}
/**
* Get single brand by ID
*/
getBrand(id: number): Observable<Brand> {
return this.http.get<Brand>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
.pipe(
catchError(this.handleError)
);
}
/**
* Create new brand (with FormData for image upload)
* ⭐ NOTE: Don't add created_by to FormData
* Backend automatically extracts it from JWT token
*/
createBrand(formData: FormData): Observable<Brand> {
const token = localStorage.getItem('accessToken');
const headers = new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
// Don't set Content-Type for FormData - browser sets it automatically with boundary
});
return this.http.post<Brand>(this.apiUrl, formData, { headers })
.pipe(
catchError(this.handleError)
);
}
/**
* Update existing brand (with FormData for image upload)
* ⭐ NOTE: Don't update created_by
* It's set once during creation and shouldn't change
*/
updateBrand(id: number, formData: FormData): Observable<Brand> {
const token = localStorage.getItem('accessToken');
const headers = new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
// Don't set Content-Type for FormData - browser sets it automatically with boundary
});
return this.http.put<Brand>(`${this.apiUrl}/${id}`, formData, { headers })
.pipe(
catchError(this.handleError)
);
}
/**
* Delete brand
*/
deleteBrand(id: number): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
.pipe(
catchError(this.handleError)
);
}
/**
* Search brands by query
*/
searchBrands(query: string): Observable<Brand[]> {
return this.http.get<Brand[]>(`${this.apiUrl}/search?q=${encodeURIComponent(query)}`,
{ headers: this.getHeaders() })
.pipe(
catchError(this.handleError)
);
}
/**
* Get brands by branch
*/
getBrandsByBranch(branchId: string): Observable<Brand[]> {
return this.http.get<Brand[]>(`${this.apiUrl}/by-branch/${branchId}`,
{ headers: this.getHeaders() })
.pipe(
catchError(this.handleError)
);
}
/**
* Handle HTTP errors
*/
private handleError(error: any): Observable<never> {
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
}));
}
}

View File

@ -1 +1,329 @@
<p>categories works!</p>
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
<!-- Header -->
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
@if (isLoading) {
<div class="absolute inset-x-0 bottom-0">
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
</div>
}
<div class="text-4xl font-extrabold tracking-tight">Category Management</div>
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
[placeholder]="'Search categories'" />
</mat-form-field>
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createCategory()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2 mr-1">Add Category</span>
</button>
</div>
</div>
<!-- Main -->
<div class="flex flex-auto overflow-hidden">
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
@if (categories$ | async; as categories) {
@if (categories.length > 0 || selectedCategory) {
<div class="grid">
<!-- Header -->
<div class="category-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort matSortDisableClear>
<div [mat-sort-header]="'category_id'">Category ID</div>
<div [mat-sort-header]="'name'">Name</div>
<div class="hidden sm:block">Image</div>
<div class="hidden md:block" [mat-sort-header]="'brand_name'">Brand</div>
<div class="hidden lg:block" [mat-sort-header]="'branch_name'">Branch</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block">Created By</div>
<div class="text-center">Actions</div>
</div>
<!-- New Category Form Row -->
@if (selectedCategory && !selectedCategory.id) {
<div class="category-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
<div class="truncate"><span class="text-xs font-medium text-blue-600">Auto-generated</span></div>
<div class="truncate font-medium text-blue-600">New Category</div>
<div class="hidden sm:block text-blue-600 text-sm">-</div>
<div class="hidden md:block text-blue-600 text-sm">-</div>
<div class="hidden lg:block text-blue-600 text-sm">-</div>
<!-- ⭐ NEW: Created By (Auto) -->
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>Auto</span>
</div>
<div class="flex items-center justify-center gap-2">
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
</button>
</div>
</div>
<div class="grid">
<ng-container
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedCategory }"></ng-container>
</div>
}
<!-- Existing Categories -->
@for (category of categories; track trackByFn($index, category)) {
<div class="category-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
<div class="truncate">
<span class="text-xs font-mono text-blue-600 font-semibold">{{ category.category_id }}</span>
</div>
<div class="truncate">
<div class="font-semibold text-gray-900">{{ category.name }}</div>
</div>
<div class="hidden sm:flex items-center">
@if (category.image) {
<img [src]="getFullImageUrl(category.image)" [alt]="category.name"
class="h-10 w-10 rounded object-cover border">
} @else {
<div class="h-10 w-10 rounded bg-gray-100 flex items-center justify-center border">
<mat-icon class="icon-size-5 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
</div>
}
</div>
<div class="hidden md:block truncate">
<div class="text-sm text-gray-600">{{ category.brand_name }}</div>
<div class="text-xs text-gray-400">{{ category.brand_id }}</div>
</div>
<div class="hidden lg:block truncate">
<div class="text-sm text-gray-600">{{ category.branch_name }}</div>
<div class="text-xs text-gray-400">{{ category.branch_id }}</div>
</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block flex items-center gap-1">
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>{{ category.created_by_username || 'N/A' }}</span>
</div>
<div class="flex items-center justify-center gap-1">
<button mat-icon-button (click)="toggleDetails(category.id)"
[matTooltip]="selectedCategory?.id === category.id ? 'Hide Details' : 'View Details'">
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
selectedCategory?.id === category.id
? 'heroicons_outline:chevron-up'
: 'heroicons_outline:chevron-down'
"></mat-icon>
</button>
<button mat-icon-button (click)="editCategory(category)" matTooltip="Edit">
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
</button>
<button mat-icon-button (click)="deleteCategory(category)" matTooltip="Delete">
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
</button>
</div>
</div>
@if (selectedCategory?.id === category.id) {
<div class="grid">
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: category }"></ng-container>
</div>
}
}
</div>
<mat-paginator
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
[showFirstLastButtons]="true"></mat-paginator>
} @else {
<div class="flex flex-col items-center justify-center p-16">
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:squares-2x2'"></mat-icon>
</div>
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No categories found</h3>
<p class="text-sm text-gray-500 mb-6">Get started by creating your first category</p>
<button mat-flat-button [color]="'primary'" (click)="createCategory()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2">Add Category</span>
</button>
</div>
}
}
<!-- Row Details Template -->
<ng-template #rowDetailsTemplate let-category>
<div class="overflow-hidden bg-white border-b">
<div class="flex">
<form class="flex w-full flex-col" [formGroup]="selectedCategoryForm">
<div class="p-8">
<div class="mb-6 pb-6 border-b">
<h2 class="text-xl font-semibold text-gray-900">
{{ category.id ? 'Edit Category' : 'Create New Category' }}
</h2>
@if (category.category_id) {
<p class="text-sm text-gray-500 mt-1">Category ID: <span class="font-mono text-blue-600">{{ category.category_id }}</span></p>
} @else {
<p class="text-sm text-gray-500 mt-1">Category ID will be auto-generated from name</p>
}
</div>
<!-- Form Fields -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Category Name -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Category Name</mat-label>
<input matInput [formControlName]="'name'" placeholder="e.g., Beverages"
(input)="onNameChange($event)" />
<mat-error *ngIf="selectedCategoryForm.get('name')?.hasError('required')">
Name is required
</mat-error>
<mat-hint>ID will be: {{ previewCategoryId }}</mat-hint>
</mat-form-field>
<!-- Branch Selection -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch</mat-label>
<mat-select [formControlName]="'branch_id'" (selectionChange)="onBranchChange($event)">
<mat-option *ngFor="let branch of branches" [value]="branch.branch_id">
{{ branch.name }} ({{ branch.code }})
</mat-option>
</mat-select>
<mat-error *ngIf="selectedCategoryForm.get('branch_id')?.hasError('required')">
Branch is required
</mat-error>
</mat-form-field>
<!-- Auto-filled Branch ID -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch ID</mat-label>
<input matInput [value]="selectedBranchId" readonly />
<mat-hint>Auto-filled from branch selection</mat-hint>
</mat-form-field>
<!-- Auto-filled Branch Name -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch Name</mat-label>
<input matInput [value]="selectedBranchName" readonly />
<mat-hint>Auto-filled from branch selection</mat-hint>
</mat-form-field>
<!-- Brand Selection (filtered by branch) -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Brand</mat-label>
<mat-select [formControlName]="'brand_id'" (selectionChange)="onBrandChange($event)"
[disabled]="!selectedBranchId">
@if (!selectedBranchId) {
<mat-option disabled>Select branch first</mat-option>
}
<mat-option *ngFor="let brand of filteredBrands" [value]="brand.brand_id">
{{ brand.name }}
</mat-option>
</mat-select>
<mat-error *ngIf="selectedCategoryForm.get('brand_id')?.hasError('required')">
Brand is required
</mat-error>
<mat-hint>Brands filtered by selected branch</mat-hint>
</mat-form-field>
<!-- Auto-filled Brand ID -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Brand ID</mat-label>
<input matInput [value]="selectedBrandId" readonly />
<mat-hint>Auto-filled from brand selection</mat-hint>
</mat-form-field>
<!-- ⭐ NEW: Created By Field (Read-only) -->
@if (category.id && category.created_by_username) {
<mat-form-field class="w-full" appearance="outline">
<mat-label>Created By</mat-label>
<input
matInput
[value]="category.created_by_username"
readonly
/>
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
</mat-form-field>
}
<!-- Category Image Upload -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">Category Image (Optional)</label>
<div class="flex items-center gap-4">
<div class="flex-shrink-0">
@if (imagePreview || category.image) {
<img [src]="imagePreview || getFullImageUrl(category.image)" [alt]="category.name"
class="h-24 w-24 rounded-lg object-cover border-2 border-gray-200">
} @else {
<div class="h-24 w-24 rounded-lg bg-gray-100 flex items-center justify-center border-2 border-dashed border-gray-300">
<mat-icon class="icon-size-8 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
</div>
}
</div>
<div class="flex-1">
<label class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<mat-icon class="icon-size-5 mr-2" [svgIcon]="'heroicons_outline:cloud-arrow-up'"></mat-icon>
Upload Image
<input type="file" accept="image/*" class="hidden" (change)="onImageSelected($event)">
</label>
<p class="mt-2 text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
@if (imagePreview || category.image) {
<button type="button" mat-button color="warn" class="mt-2" (click)="removeImage()">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
Remove Image
</button>
}
</div>
</div>
</div>
</div>
@if (category.id) {
<div class="mt-6 pt-6 border-t">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Created:</span>
<span class="ml-2 text-gray-900">{{ category.created_at | date:'medium' }}</span>
</div>
@if (category.updated_at && category.updated_at !== category.created_at) {
<div>
<span class="text-gray-500">Last Updated:</span>
<span class="ml-2 text-gray-900">{{ category.updated_at | date:'medium' }}</span>
</div>
}
</div>
</div>
}
</div>
<!-- Action Buttons -->
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
<button mat-button [color]="'warn'" (click)="deleteSelectedCategory()"
type="button" [disabled]="!category.id">
Delete
</button>
<div class="flex items-center gap-3">
@if (flashMessage) {
<div class="flex items-center text-sm">
@if (flashMessage === 'success') {
<mat-icon class="icon-size-5 text-green-600 mr-2"
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
<span class="text-green-600">{{ category.id ? 'Updated' : 'Created' }} successfully</span>
}
@if (flashMessage === 'error') {
<mat-icon class="icon-size-5 text-red-600 mr-2"
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
<span class="text-red-600">Error occurred</span>
}
</div>
}
<button mat-stroked-button (click)="closeDetails()" type="button">
Cancel
</button>
<button mat-flat-button [color]="'primary'" (click)="updateSelectedCategory()"
type="button" [disabled]="selectedCategoryForm.invalid || isLoading">
@if (isLoading) {
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
}
<span>{{ category.id ? 'Update' : 'Create' }}</span>
</button>
</div>
</div>
</form>
</div>
</div>
</ng-template>
</div>
</div>
</div>

View File

@ -0,0 +1,291 @@
/* Category grid layout - NOW 7 COLUMNS WITH CREATED_BY */
.category-grid {
// Category ID, Name, Image, Brand, Branch, Created By, Actions (7 columns)
grid-template-columns: 120px 1fr 80px 150px 150px 150px 120px;
@media (max-width: 1279px) { // xl breakpoint - hide Created By
grid-template-columns: 120px 1fr 80px 150px 150px 120px;
}
@media (max-width: 1023px) { // lg - hide branch
grid-template-columns: 120px 1fr 80px 150px 120px;
}
@media (max-width: 767px) { // md - hide brand
grid-template-columns: 120px 1fr 80px 120px;
}
@media (max-width: 639px) { // sm - hide image
grid-template-columns: 120px 1fr 80px;
}
}
/* Created By Column Styling */
.category-grid > div:nth-child(6) {
display: flex;
align-items: center;
gap: 6px;
}
.category-grid > div:nth-child(6) mat-icon {
color: #6b7280;
font-size: 16px;
width: 16px;
height: 16px;
}
.category-grid > div:nth-child(6) span {
font-size: 13px;
color: #374151;
}
.dark .category-grid > div:nth-child(6) mat-icon {
color: #9ca3af;
}
.dark .category-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 sizes */
.icon-size-5 {
width: 20px;
height: 20px;
font-size: 20px;
}
.icon-size-8 {
width: 32px;
height: 32px;
font-size: 32px;
}
.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;
}
}
/* Row hover effect */
.category-grid > div:not(.sticky):not(.bg-blue-50) {
transition: background-color 0.15s ease;
}
/* Loading animation */
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Responsive adjustments */
@media (max-width: 768px) {
.category-grid {
gap: 8px;
padding: 12px 16px;
}
}
/* Dark mode */
@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);
}
.category-grid > div:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
}
/* Custom scrollbar */
::-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 */
.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;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* Pagination */
mat-paginator {
background-color: transparent;
}
/* Typography */
.font-mono {
letter-spacing: 0.05em;
}
/* Form appearance */
.mat-mdc-form-field-appearance-outline {
.mat-mdc-form-field-outline {
color: #e5e7eb;
}
&.mat-focused .mat-mdc-form-field-outline {
color: #3b82f6;
}
}
/* Hints and errors */
.mat-mdc-form-field-hint {
font-size: 12px;
color: #6b7280;
}
.mat-mdc-form-field-error {
font-size: 12px;
}
/* Button heights */
.mat-mdc-raised-button,
.mat-mdc-outlined-button {
height: 40px;
line-height: 40px;
}
/* Dividers */
.border-b, .border-t {
border-color: #e5e7eb;
}
/* Typography hierarchy */
h2 {
font-weight: 600;
line-height: 1.2;
}
/* Shadows */
.overflow-hidden {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
/* Action buttons */
.flex.gap-1 button:hover {
background-color: rgba(0, 0, 0, 0.04);
}
/* Smooth transitions */
.transition-colors {
transition-property: background-color, color;
transition-duration: 200ms;
}
/* Badge styling */
.text-blue-600 {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Progress bar */
mat-progress-bar {
height: 2px;
}
/* Tooltips */
.mat-mdc-tooltip {
font-size: 12px;
padding: 6px 12px;
}
/* Responsive typography */
@media (max-width: 640px) {
.text-4xl {
font-size: 1.875rem;
}
}
/* Focus ring */
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Snackbar */
.snackbar-success {
background-color: #10b981;
}
.snackbar-error {
background-color: #ef4444;
}

View File

@ -1,12 +1,337 @@
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 { 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 { MatSelectModule } from '@angular/material/select';
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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs';
import { CategoryService, Category } from './category.service';
import { BranchService, Branch } from '../../company/branch-list/branch.service';
import { BrandService, Brand } from '../brand/brand.service';
@Component({
selector: 'app-categories',
standalone: true,
imports: [],
imports: [
CommonModule, ReactiveFormsModule, MatPaginatorModule, MatSortModule,
MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule,
MatIconModule, MatTooltipModule, MatProgressBarModule, MatSnackBarModule
],
templateUrl: './categories.component.html',
styleUrl: './categories.component.scss'
})
export class CategoriesComponent {
export class CategoriesComponent implements OnInit, OnDestroy {
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
}
categories$: Observable<Category[]>;
private categoriesSubject = new BehaviorSubject<Category[]>([]);
private destroy$ = new Subject<void>();
searchInputControl = new FormControl('');
selectedCategoryForm!: FormGroup;
selectedCategory: Category | null = null;
isLoading = false;
flashMessage: 'success' | 'error' | null = null;
pagination = { length: 0, page: 0, size: 10 };
branches: Branch[] = [];
brands: Brand[] = [];
filteredBrands: Brand[] = [];
selectedBranchId = '';
selectedBranchName = '';
selectedBrandId = '';
previewCategoryId = 'XXX0001';
imagePreview: string | null = null;
selectedImageFile: File | null = null;
constructor(
private fb: FormBuilder,
private categoryService: CategoryService,
private branchService: BranchService,
private brandService: BrandService,
private snackBar: MatSnackBar
) {
this.categories$ = this.categoriesSubject.asObservable();
this.initializeForm();
}
ngOnInit(): void {
this.loadBranches();
this.loadBrands();
this.loadCategories();
this.setupSearch();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private initializeForm(): void {
this.selectedCategoryForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
branch_id: ['', [Validators.required]],
brand_id: ['', [Validators.required]]
});
}
private setupSearch(): void {
this.searchInputControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
).subscribe((searchTerm) => {
this.filterCategories(searchTerm || '');
});
}
loadBranches(): void {
this.branchService.getAllBranches().pipe(takeUntil(this.destroy$))
.subscribe({
next: (branches) => { this.branches = branches; },
error: (error) => this.showSnackBar('Failed to load branches', 'error')
});
}
loadBrands(): void {
this.brandService.getAllBrands().pipe(takeUntil(this.destroy$))
.subscribe({
next: (brands) => { this.brands = brands; },
error: (error) => this.showSnackBar('Failed to load brands', 'error')
});
}
loadCategories(): void {
this.isLoading = true;
this.categoryService.getAllCategories().pipe(takeUntil(this.destroy$))
.subscribe({
next: (categories) => {
this.categoriesSubject.next(categories);
this.pagination.length = categories.length;
this.isLoading = false;
},
error: (error) => {
this.showSnackBar('Failed to load categories', 'error');
this.isLoading = false;
}
});
}
private filterCategories(searchTerm: string): void {
this.categoryService.getAllCategories().pipe(
map(categories => {
if (!searchTerm) return categories;
const term = searchTerm.toLowerCase();
return categories.filter(cat =>
cat.name.toLowerCase().includes(term) ||
cat.category_id.toLowerCase().includes(term) ||
cat.brand_name.toLowerCase().includes(term)
);
}),
takeUntil(this.destroy$)
).subscribe(filtered => {
this.categoriesSubject.next(filtered);
this.pagination.length = filtered.length;
});
}
onNameChange(event: any): void {
const name = event.target.value;
this.previewCategoryId = name ? this.generateCategoryIdPreview(name) : 'XXX0001';
}
private generateCategoryIdPreview(name: string): string {
const lettersOnly = name.replace(/[^a-zA-Z]/g, '').toUpperCase();
const prefix = lettersOnly.substring(0, 3).padEnd(3, 'X');
return `${prefix}0001`;
}
onBranchChange(event: any): void {
const branchId = event.value;
const branch = this.branches.find(b => b.branch_id === branchId);
if (branch) {
this.selectedBranchId = branch.branch_id;
this.selectedBranchName = branch.name;
this.filteredBrands = this.brands.filter(b => b.branch_id === branchId);
this.selectedCategoryForm.patchValue({ brand_id: '' });
this.selectedBrandId = '';
}
}
onBrandChange(event: any): void {
const brandId = event.value;
const brand = this.brands.find(b => b.brand_id === brandId);
this.selectedBrandId = brand ? brand.brand_id : '';
}
onImageSelected(event: any): void {
const file = event.target.files[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
this.showSnackBar('Image size must be less than 5MB', 'error');
return;
}
if (!file.type.startsWith('image/')) {
this.showSnackBar('Please select an image file', 'error');
return;
}
this.selectedImageFile = file;
const reader = new FileReader();
reader.onload = (e: any) => { this.imagePreview = e.target.result; };
reader.readAsDataURL(file);
}
}
removeImage(): void {
this.imagePreview = null;
this.selectedImageFile = null;
if (this.selectedCategory?.id) this.selectedCategory.image = null;
}
getFullImageUrl(imagePath: string | null): string {
if (!imagePath) return '';
if (imagePath.startsWith('http')) return imagePath;
return `http://localhost:5000${imagePath}`;
}
createCategory(): void {
this.selectedCategory = {
id: 0, category_id: '', name: '', image: null,
brand_id: '', brand_name: '', branch_id: '', branch_name: '',
created_at: '', updated_at: ''
};
this.selectedCategoryForm.reset();
this.selectedBranchId = '';
this.selectedBranchName = '';
this.selectedBrandId = '';
this.filteredBrands = [];
this.previewCategoryId = 'XXX0001';
this.imagePreview = null;
this.selectedImageFile = null;
this.flashMessage = null;
}
editCategory(category: Category): void {
this.selectedCategory = { ...category };
this.selectedCategoryForm.patchValue({
name: category.name,
branch_id: category.branch_id,
brand_id: category.brand_id
});
this.selectedBranchId = category.branch_id;
this.selectedBranchName = category.branch_name;
this.selectedBrandId = category.brand_id;
this.filteredBrands = this.brands.filter(b => b.branch_id === category.branch_id);
this.previewCategoryId = category.category_id;
this.imagePreview = category.image ? this.getFullImageUrl(category.image) : null;
this.selectedImageFile = null;
this.flashMessage = null;
}
toggleDetails(categoryId: number): void {
if (this.selectedCategory?.id === categoryId) {
this.closeDetails();
} else {
const category = this.categoriesSubject.value.find(c => c.id === categoryId);
if (category) this.editCategory(category);
}
}
closeDetails(): void {
this.selectedCategory = null;
this.selectedCategoryForm.reset();
this.selectedBranchId = '';
this.selectedBranchName = '';
this.selectedBrandId = '';
this.filteredBrands = [];
this.previewCategoryId = 'XXX0001';
this.imagePreview = null;
this.selectedImageFile = null;
this.flashMessage = null;
}
updateSelectedCategory(): void {
if (this.selectedCategoryForm.invalid) {
this.selectedCategoryForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formData = new FormData();
formData.append('name', this.selectedCategoryForm.get('name')?.value);
formData.append('branch_id', this.selectedCategoryForm.get('branch_id')?.value);
formData.append('brand_id', this.selectedCategoryForm.get('brand_id')?.value);
if (this.selectedImageFile) formData.append('image', this.selectedImageFile);
const operation = this.selectedCategory?.id
? this.categoryService.updateCategory(this.selectedCategory.id, formData)
: this.categoryService.createCategory(formData);
operation.pipe(takeUntil(this.destroy$)).subscribe({
next: (category) => {
this.flashMessage = 'success';
this.isLoading = false;
this.showSnackBar(
this.selectedCategory?.id ? 'Category updated successfully' : 'Category created successfully',
'success'
);
setTimeout(() => {
this.closeDetails();
this.loadCategories();
}, 1500);
},
error: (error) => {
this.flashMessage = 'error';
this.isLoading = false;
this.showSnackBar(error.error?.error || 'Failed to save category', 'error');
}
});
}
deleteSelectedCategory(): void {
if (!this.selectedCategory?.id) return;
if (!confirm(`Are you sure you want to delete category "${this.selectedCategory.name}"?`)) return;
this.deleteCategory(this.selectedCategory);
}
deleteCategory(category: Category): void {
if (!confirm(`Are you sure you want to delete category "${category.name}"?`)) return;
this.isLoading = true;
this.categoryService.deleteCategory(category.id).pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.showSnackBar('Category deleted successfully', 'success');
this.closeDetails();
this.loadCategories();
},
error: (error) => {
this.showSnackBar(error.error?.error || 'Failed to delete category', 'error');
this.isLoading = false;
}
});
}
trackByFn(index: number, item: Category): number {
return item.id;
}
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'
});
}
}

View File

@ -0,0 +1,110 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from '../../../../../../environments/environment';
export interface Category {
id: number;
category_id: string;
name: string;
image: string | null;
brand_id: string;
brand_name: string;
branch_id: string;
branch_name: string;
created_at: string;
updated_at: string;
// ⭐ NEW: Created by fields (automatic from backend)
created_by?: number;
created_by_username?: string;
}
@Injectable({
providedIn: 'root'
})
export class CategoryService {
private apiUrl = `${environment.apiUrl}/categories`;
constructor(private http: HttpClient) {}
private getHeaders(): HttpHeaders {
const token = localStorage.getItem('accessToken');
return new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
});
}
getAllCategories(): Observable<Category[]> {
return this.http.get<Category[]>(this.apiUrl, { headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
getCategory(id: number): Observable<Category> {
return this.http.get<Category>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
/**
* Create new category (with FormData for image upload)
* ⭐ NOTE: Don't add created_by to FormData
* Backend automatically extracts it from JWT token
*/
createCategory(formData: FormData): Observable<Category> {
const token = localStorage.getItem('accessToken');
const headers = new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
});
return this.http.post<Category>(this.apiUrl, formData, { headers })
.pipe(catchError(this.handleError));
}
/**
* Update existing category (with FormData for image upload)
* ⭐ NOTE: Don't update created_by
* It's set once during creation and shouldn't change
*/
updateCategory(id: number, formData: FormData): Observable<Category> {
const token = localStorage.getItem('accessToken');
const headers = new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
});
return this.http.put<Category>(`${this.apiUrl}/${id}`, formData, { headers })
.pipe(catchError(this.handleError));
}
deleteCategory(id: number): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
searchCategories(query: string): Observable<Category[]> {
return this.http.get<Category[]>(`${this.apiUrl}/search?q=${encodeURIComponent(query)}`,
{ headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
getCategoriesByBrand(brandId: string): Observable<Category[]> {
return this.http.get<Category[]>(`${this.apiUrl}/by-brand/${brandId}`,
{ headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
getCategoriesByBranch(branchId: string): Observable<Category[]> {
return this.http.get<Category[]>(`${this.apiUrl}/by-branch/${branchId}`,
{ headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
private handleError(error: any): Observable<never> {
console.error('API Error:', error);
let errorMessage = 'An error occurred';
if (error.error instanceof ErrorEvent) {
errorMessage = error.error.message;
} else {
errorMessage = error.error?.error || error.message || 'Server error';
}
return throwError(() => ({ error: errorMessage, status: error.status }));
}
}

View File

@ -54,7 +54,7 @@
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
@if (products && products.length > 0 || showAddForm) {
<div class="grid">
<!-- Header -->
<!-- Header - NOW WITH 9 COLUMNS -->
<div
class="inventory-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort
@ -64,19 +64,19 @@
<div [mat-sort-header]="'product_id'">Product ID</div>
<div [mat-sort-header]="'product_name'">Product Name</div>
<div class="hidden sm:block" [mat-sort-header]="'price'">Price</div>
<div class="hidden lg:block" [mat-sort-header]="'billing_date'">Billing Date</div>
<div class="hidden lg:block" [mat-sort-header]="'expiration_date'">Expiration Date</div>
<div class="hidden lg:block" [mat-sort-header]="'created_date'">Created Date</div>
<div class="hidden xl:block">Created By</div>
<div class="hidden sm:block">Details</div>
</div>
<!-- Add Product Form (when toggled) -->
<!-- Add Product Form -->
@if (showAddForm) {
<div class="grid">
<div class="inventory-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-3 md:px-8 dark:bg-blue-900 dark:bg-opacity-20">
<!-- Empty space for image column -->
<div></div>
<!-- Auto-generated ID placeholder -->
<div class="text-gray-500 italic">Auto-generated</div>
<!-- Product name input -->
<div>
<mat-form-field class="w-full fuse-mat-dense">
<input
@ -87,7 +87,6 @@
/>
</mat-form-field>
</div>
<!-- Price input -->
<div class="hidden sm:block">
<mat-form-field class="w-full fuse-mat-dense">
<span matPrefix></span>
@ -100,11 +99,15 @@
/>
</mat-form-field>
</div>
<!-- Current date display -->
<div class="hidden lg:block text-gray-500">-</div>
<div class="hidden lg:block text-gray-500">-</div>
<div class="hidden lg:block text-gray-500">
{{ currentDate | date : 'fullDate' }}
</div>
<!-- Action buttons -->
<div class="hidden xl:block text-gray-500 flex items-center gap-1">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>Auto</span>
</div>
<div class="flex gap-2">
<button
mat-icon-button
@ -205,6 +208,37 @@
</mat-select>
</mat-form-field>
</div>
<!-- Billing and Expiration Date Row -->
<div class="flex gap-4">
<mat-form-field class="flex-1">
<mat-label>Billing Date</mat-label>
<input
matInput
[matDatepicker]="billingPicker"
formControlName="billing_date"
/>
<mat-datepicker-toggle matIconSuffix [for]="billingPicker"></mat-datepicker-toggle>
<mat-datepicker #billingPicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-1">
<mat-label>Expiration Date</mat-label>
<input
matInput
[matDatepicker]="expirationPicker"
formControlName="expiration_date"
/>
<mat-datepicker-toggle matIconSuffix [for]="expirationPicker"></mat-datepicker-toggle>
<mat-datepicker #expirationPicker></mat-datepicker>
</mat-form-field>
</div>
<!-- Created By Info -->
<div class="mt-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:information-circle'"></mat-icon>
<span>Creator will be set automatically from your account</span>
</div>
</div>
</div>
</div>
@ -263,6 +297,10 @@
<div class="grid">
<div
class="inventory-grid grid items-center gap-4 border-b px-6 py-3 md:px-8"
[ngClass]="{
'bg-red-50 dark:bg-red-900 dark:bg-opacity-20': isExpired(product),
'bg-yellow-50 dark:bg-yellow-900 dark:bg-opacity-20': isExpiringSoon(product) && !isExpired(product)
}"
>
<!-- Image -->
<div class="flex items-center">
@ -287,21 +325,78 @@
</div>
<!-- Product ID -->
<div class="truncate">{{ product.product_id }}</div>
<div class="truncate" [ngClass]="{
'text-red-700 dark:text-red-300 font-semibold': isExpired(product),
'text-yellow-700 dark:text-yellow-300 font-semibold': isExpiringSoon(product) && !isExpired(product)
}">{{ product.product_id }}</div>
<!-- Name -->
<div class="truncate">{{ product.product_name }}</div>
<div class="truncate" [ngClass]="{
'text-red-700 dark:text-red-300 font-semibold': isExpired(product),
'text-yellow-700 dark:text-yellow-300 font-semibold': isExpiringSoon(product) && !isExpired(product)
}">{{ product.product_name }}</div>
<!-- Price -->
<div class="hidden sm:block">
<div class="hidden sm:block" [ngClass]="{
'text-red-700 dark:text-red-300 font-semibold': isExpired(product),
'text-yellow-700 dark:text-yellow-300 font-semibold': isExpiringSoon(product) && !isExpired(product)
}">
{{ product.price | currency : 'INR' : 'symbol' : '1.2-2' }}
</div>
<!-- Created Date -->
<!-- Billing Date -->
<div class="hidden lg:block" [ngClass]="{
'text-red-700 dark:text-red-300': isExpired(product),
'text-yellow-700 dark:text-yellow-300': isExpiringSoon(product) && !isExpired(product)
}">
@if (product.billing_date) {
{{ product.billing_date | date : 'mediumDate' }}
} @else {
<span class="text-gray-400">N/A</span>
}
</div>
<!-- Expiration Date -->
<div class="hidden lg:block">
@if (product.expiration_date) {
<div class="flex items-center gap-2">
@if (isExpired(product)) {
<mat-icon class="icon-size-4 text-red-600 dark:text-red-400" [svgIcon]="'heroicons_outline:exclamation-circle'"></mat-icon>
<span class="text-red-600 dark:text-red-400 font-semibold">
{{ product.expiration_date | date : 'mediumDate' }}
</span>
} @else if (isExpiringSoon(product)) {
<mat-icon class="icon-size-4 text-yellow-600 dark:text-yellow-400" [svgIcon]="'heroicons_outline:exclamation-triangle'"></mat-icon>
<span class="text-yellow-600 dark:text-yellow-400 font-semibold">
{{ product.expiration_date | date : 'mediumDate' }}
</span>
<span class="text-xs text-yellow-600 dark:text-yellow-400">({{ getDaysUntilExpiration(product) }} days)</span>
} @else {
<span>{{ product.expiration_date | date : 'mediumDate' }}</span>
}
</div>
} @else {
<span class="text-gray-400">N/A</span>
}
</div>
<!-- Created Date -->
<div class="hidden lg:block" [ngClass]="{
'text-red-700 dark:text-red-300': isExpired(product),
'text-yellow-700 dark:text-yellow-300': isExpiringSoon(product) && !isExpired(product)
}">
{{ product.created_date | date : 'fullDate' }}
</div>
<!-- Created By -->
<div class="hidden xl:block flex items-center gap-1" [ngClass]="{
'text-red-700 dark:text-red-300': isExpired(product),
'text-yellow-700 dark:text-yellow-300': isExpiringSoon(product) && !isExpired(product)
}">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>{{ product.created_by_username || 'N/A' }}</span>
</div>
<!-- Details button -->
<div>
<button
@ -422,6 +517,63 @@
}
</mat-select>
</mat-form-field>
<!-- Billing and Expiration Date Row -->
<div class="flex gap-4">
<mat-form-field class="flex-1">
<mat-label>Billing Date</mat-label>
<input
matInput
[matDatepicker]="editBillingPicker"
formControlName="billing_date"
/>
<mat-datepicker-toggle matIconSuffix [for]="editBillingPicker"></mat-datepicker-toggle>
<mat-datepicker #editBillingPicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-1">
<mat-label>Expiration Date</mat-label>
<input
matInput
[matDatepicker]="editExpirationPicker"
formControlName="expiration_date"
/>
<mat-datepicker-toggle matIconSuffix [for]="editExpirationPicker"></mat-datepicker-toggle>
<mat-datepicker #editExpirationPicker></mat-datepicker>
</mat-form-field>
</div>
<!-- Expiration Warning -->
@if (isExpiringSoon(product) || isExpired(product)) {
<div class="mt-2 flex items-center gap-2 p-3 rounded"
[ngClass]="{
'bg-red-100 text-red-700 dark:bg-red-900 dark:bg-opacity-30 dark:text-red-200': isExpired(product),
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:bg-opacity-30 dark:text-yellow-200': isExpiringSoon(product) && !isExpired(product)
}">
<mat-icon class="icon-size-5"
[svgIcon]="isExpired(product) ? 'heroicons_outline:exclamation-circle' : 'heroicons_outline:exclamation-triangle'"></mat-icon>
<span class="font-medium">
@if (isExpired(product)) {
This product has expired!
} @else {
This product expires in {{ getDaysUntilExpiration(product) }} days!
}
</span>
</div>
}
<!-- Created By - Display only (read-only) -->
@if (product.created_by_username) {
<mat-form-field class="w-full">
<mat-label>Created By</mat-label>
<input
matInput
[value]="product.created_by_username"
readonly
/>
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
</mat-form-field>
}
</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
/* Product Management Component Styles */
/* Product Management Component Styles - UPDATED FOR 9 COLUMNS */
/* Grid Layout for Product List */
/* Grid Layout for Product List - NOW 9 COLUMNS */
.inventory-grid {
display: grid;
grid-template-columns: 80px 120px 1fr 140px 200px 80px;
grid-template-columns: 80px 120px 1fr 140px 140px 140px 160px 150px 80px;
align-items: center;
gap: 16px;
}
@ -15,18 +15,129 @@
}
/* Responsive Grid Adjustments */
@media (max-width: 1400px) {
/* Hide Created By column on smaller screens */
.inventory-grid {
grid-template-columns: 80px 120px 1fr 140px 140px 140px 160px 80px;
}
.inventory-grid > div:nth-child(8) {
display: none !important;
}
}
@media (max-width: 1024px) {
/* Hide date columns on medium screens */
.inventory-grid {
grid-template-columns: 80px 120px 1fr 140px 80px;
}
.inventory-grid > div:nth-child(5),
.inventory-grid > div:nth-child(6),
.inventory-grid > div:nth-child(7) {
display: none !important;
}
}
@media (max-width: 768px) {
/* Minimal columns on small screens */
.inventory-grid {
grid-template-columns: 80px 1fr 120px 80px;
}
}
/* Created By Column Styling */
.inventory-grid > div:nth-child(8) {
display: flex;
align-items: center;
gap: 6px;
}
.inventory-grid > div:nth-child(8) mat-icon {
color: #6b7280;
font-size: 16px;
width: 16px;
height: 16px;
}
.inventory-grid > div:nth-child(8) span {
font-size: 13px;
color: #374151;
}
.dark .inventory-grid > div:nth-child(8) mat-icon {
color: #9ca3af;
}
.dark .inventory-grid > div:nth-child(8) span {
color: #d1d5db;
}
/* Expiration Warning Styles */
.bg-red-50 {
background-color: rgba(254, 226, 226, 0.5) !important;
}
.bg-yellow-50 {
background-color: rgba(254, 249, 195, 0.5) !important;
}
.dark .bg-red-900 {
background-color: rgba(127, 29, 29, 0.2) !important;
}
.dark .bg-yellow-900 {
background-color: rgba(120, 53, 15, 0.2) !important;
}
.text-red-600 {
color: rgb(220, 38, 38) !important;
}
.text-yellow-600 {
color: rgb(202, 138, 4) !important;
}
.text-red-700 {
color: rgb(185, 28, 28) !important;
}
.text-yellow-700 {
color: rgb(161, 98, 7) !important;
}
.dark .text-red-300 {
color: rgb(252, 165, 165) !important;
}
.dark .text-yellow-300 {
color: rgb(253, 224, 71) !important;
}
.dark .text-red-400 {
color: rgb(248, 113, 113) !important;
}
.dark .text-yellow-400 {
color: rgb(250, 204, 21) !important;
}
.dark .text-red-200 {
color: rgb(254, 202, 202) !important;
}
.dark .text-yellow-200 {
color: rgb(254, 240, 138) !important;
}
.bg-red-100 {
background-color: rgb(254, 226, 226);
}
.bg-yellow-100 {
background-color: rgb(254, 249, 195);
}
/* Product Image Styles */
.product-image-container {
position: relative;
@ -385,4 +496,54 @@ button[mat-stroked-button] {
&:hover {
background: #a8a8a8;
}
}
/* Date Field Styling */
.mat-mdc-form-field {
.mat-datepicker-toggle {
color: #1976d2;
}
}
/* Expiration Warning Box */
.expiration-warning {
border-radius: 6px;
padding: 12px;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-weight: 500;
mat-icon {
flex-shrink: 0;
}
&.expired {
background-color: rgb(254, 226, 226);
color: rgb(185, 28, 28);
border: 1px solid rgb(252, 165, 165);
}
&.expiring-soon {
background-color: rgb(254, 249, 195);
color: rgb(161, 98, 7);
border: 1px solid rgb(253, 224, 71);
}
}
.dark {
.expiration-warning {
&.expired {
background-color: rgba(127, 29, 29, 0.3);
color: rgb(254, 202, 202);
border-color: rgba(248, 113, 113, 0.3);
}
&.expiring-soon {
background-color: rgba(120, 53, 15, 0.3);
color: rgb(254, 240, 138);
border-color: rgba(250, 204, 21, 0.3);
}
}
}

View File

@ -1,3 +1,4 @@
// src/app/products/product-list/product-list.component.ts
import { Component, OnInit, ViewChild, Input } from '@angular/core';
import { Product, ProductService } from '../product.service';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms';
@ -12,7 +13,9 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatRippleModule } from '@angular/material/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { environment } from '@environments/environment';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { environment } from '@environments/environment';
@Component({
selector: 'app-product-list',
@ -33,7 +36,9 @@ import { environment } from '@environments/environment';
MatFormFieldModule,
MatProgressBarModule,
MatRippleModule,
MatTooltipModule
MatTooltipModule,
MatDatepickerModule,
MatNativeDateModule
]
})
export class ProductListComponent implements OnInit {
@ -84,14 +89,18 @@ export class ProductListComponent implements OnInit {
price: ['', [Validators.required, Validators.min(0)]],
created_date: [{ value: new Date(), disabled: true }],
product_id: [{ value: null, disabled: true }],
category: ['']
category: [''],
billing_date: [''],
expiration_date: ['']
});
// New add product form (inline)
this.addProductForm = this.formBuilder.group({
product_name: ['', Validators.required],
price: [0, [Validators.required, Validators.min(0.01)]],
category: ['']
category: [''],
billing_date: [''],
expiration_date: ['']
});
this.searchControl.valueChanges.subscribe(query => {
@ -178,6 +187,58 @@ export class ProductListComponent implements OnInit {
product.product_image = null;
}
/**
* Check if product is expiring within 3 days
*/
isExpiringSoon(product: Product): boolean {
if (!product.expiration_date) {
return false;
}
const expirationDate = new Date(product.expiration_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(today.getDate() + 3);
threeDaysFromNow.setHours(23, 59, 59, 999);
return expirationDate <= threeDaysFromNow && expirationDate >= today;
}
/**
* Check if product is expired
*/
isExpired(product: Product): boolean {
if (!product.expiration_date) {
return false;
}
const expirationDate = new Date(product.expiration_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
return expirationDate < today;
}
/**
* Get days until expiration
*/
getDaysUntilExpiration(product: Product): number {
if (!product.expiration_date) {
return -1;
}
const expirationDate = new Date(product.expiration_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffTime = expirationDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
toggleDetails(productId: string): void {
if (this.selectedProduct?.product_id === productId) {
this.selectedProduct = null;
@ -185,31 +246,33 @@ export class ProductListComponent implements OnInit {
const prod = this.products.find(p => p.product_id === productId) || null;
this.selectedProduct = prod;
if (prod) {
// Ensure we have the numeric id for updates
console.log('Selected product for editing:', prod); // Debug log
console.log('Selected product for editing:', prod);
this.productForm.patchValue({
product_name: prod.product_name,
price: prod.price,
created_date: prod.created_date,
product_id: prod.product_id,
category: prod.category
category: prod.category,
billing_date: prod.billing_date ? new Date(prod.billing_date) : null,
expiration_date: prod.expiration_date ? new Date(prod.expiration_date) : null
});
}
}
}
// Inline Add Product Methods (replaces modal)
// Inline Add Product Methods
toggleAddProduct(): void {
if (this.showAddForm) {
this.cancelAddProduct();
} else {
// Close any open product details
this.selectedProduct = null;
this.showAddForm = true;
this.addProductForm.reset({
product_name: '',
price: 0,
category: ''
category: '',
billing_date: '',
expiration_date: ''
});
this.addProductImagePreview = null;
this.addProductSelectedFile = null;
@ -253,6 +316,18 @@ export class ProductListComponent implements OnInit {
if (category) {
formData.append('category', category);
}
// Add billing date
const billingDate = this.addProductForm.get('billing_date')?.value;
if (billingDate) {
formData.append('billing_date', this.formatDate(billingDate));
}
// Add expiration date
const expirationDate = this.addProductForm.get('expiration_date')?.value;
if (expirationDate) {
formData.append('expiration_date', this.formatDate(expirationDate));
}
if (this.addProductSelectedFile) {
formData.append('product_image', this.addProductSelectedFile);
@ -263,11 +338,12 @@ export class ProductListComponent implements OnInit {
this.isAddingProduct = false;
this.showAddFlashMessage('success');
this.loadProducts();
// Reset form but keep it open for potential additional entries
this.addProductForm.reset({
product_name: '',
price: 0,
category: ''
category: '',
billing_date: '',
expiration_date: ''
});
this.addProductImagePreview = null;
this.addProductSelectedFile = null;
@ -306,7 +382,7 @@ export class ProductListComponent implements OnInit {
updateProduct(): void {
if (!this.selectedProduct?.id) {
console.error('No product selected for update or missing numeric ID');
console.log('Selected product:', this.selectedProduct); // Debug log
console.log('Selected product:', this.selectedProduct);
this.showFlashMessage('error');
return;
}
@ -326,6 +402,18 @@ export class ProductListComponent implements OnInit {
if (category) {
formData.append('category', category);
}
// Add billing date
const billingDate = this.productForm.get('billing_date')?.value;
if (billingDate) {
formData.append('billing_date', this.formatDate(billingDate));
}
// Add expiration date
const expirationDate = this.productForm.get('expiration_date')?.value;
if (expirationDate) {
formData.append('expiration_date', this.formatDate(expirationDate));
}
if (this.selectedFile) {
formData.append('product_image', this.selectedFile);
@ -350,7 +438,6 @@ export class ProductListComponent implements OnInit {
}
deleteProduct(productId: string): void {
// Find the product to get its numeric ID
const product = this.products.find(p => p.product_id === productId);
if (!product?.id) {
console.error('Product not found or missing numeric ID:', productId);
@ -383,4 +470,16 @@ export class ProductListComponent implements OnInit {
this.selectedFile = null;
}
}
/**
* Format date for backend (YYYY-MM-DD)
*/
private formatDate(date: Date | string): string {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}

View File

@ -1,39 +1,89 @@
// src/app/products/product.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { environment } from '@environments/environment';
export interface Product {
id?: number; // Add this - the numeric primary key from backend
product_id?: string; // Keep this - the string identifier like "PA020"
id?: number;
product_id?: string;
product_name: string;
price: number;
product_image?: string | null;
created_date: Date | string;
category?: string;
selected?: boolean; // For UI selection state
// NEW: Billing and expiration dates
billing_date?: Date | string | null;
expiration_date?: Date | string | null;
// Created by fields (automatic from backend)
created_by?: number;
created_by_username?: string;
}
@Injectable({
providedIn: 'root'
})
export class ProductService {
private apiUrl = `${environment.apiUrl}/products`; // Replace with your actual API URL
private apiUrl = environment.apiUrl2; // '/products' endpoint
constructor(private http: HttpClient) {}
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl);
return this.http.get<Product[]>(this.apiUrl).pipe(
tap((products) => console.log('Fetched products:', products)),
catchError((error) => {
console.error('Error fetching products:', error);
throw error;
})
);
}
addProduct(product: FormData): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
// Backend automatically extracts created_by from JWT token
return this.http.post<Product>(this.apiUrl, product).pipe(
tap((newProduct) => console.log('Added product:', newProduct)),
catchError((error) => {
console.error('Error adding product:', error);
throw error;
})
);
}
updateProduct(id: number, product: FormData): Observable<Product> {
return this.http.put<Product>(`${this.apiUrl}/${id}`, product);
return this.http.put<Product>(`${this.apiUrl}/${id}`, product).pipe(
tap((updated) => console.log('Updated product:', updated)),
catchError((error) => {
console.error('Error updating product:', error);
throw error;
})
);
}
deleteProduct(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(
tap(() => console.log('Deleted product:', id)),
catchError((error) => {
console.error('Error deleting product:', error);
throw error;
})
);
}
/**
* Helper method to get full image URL
*/
getImageUrl(imagePath: string | null | undefined): string {
if (!imagePath) {
return 'assets/images/product-placeholder.png';
}
// Remove leading slash if present
const cleanPath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
return `${environment.apiUrl}/${cleanPath}`;
}
}

View File

@ -1 +1,367 @@
<p>sub-categories works!</p>
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
<!-- Header -->
<div class="relative flex flex-0 flex-col border-b px-6 py-8 sm:flex-row sm:items-center sm:justify-between md:px-8">
@if (isLoading) {
<div class="absolute inset-x-0 bottom-0">
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
</div>
}
<div class="text-4xl font-extrabold tracking-tight">SubCategory Management</div>
<div class="mt-6 flex shrink-0 items-center sm:ml-4 sm:mt-0">
<mat-form-field class="fuse-mat-dense fuse-mat-rounded min-w-64" [subscriptSizing]="'dynamic'">
<mat-icon class="icon-size-5" matPrefix [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
<input matInput [formControl]="searchInputControl" [autocomplete]="'off'"
[placeholder]="'Search sub-categories'" />
</mat-form-field>
<button class="ml-4" mat-flat-button [color]="'primary'" (click)="createSubCategory()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2 mr-1">Add SubCategory</span>
</button>
</div>
</div>
<!-- Main -->
<div class="flex flex-auto overflow-hidden">
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
@if (subCategories$ | async; as subCategories) {
@if (subCategories.length > 0 || selectedSubCategory) {
<div class="grid">
<!-- Header -->
<div class="subcategory-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-sm font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort matSortDisableClear>
<div [mat-sort-header]="'sub_category_id'">SubCategory ID</div>
<div [mat-sort-header]="'name'">Name</div>
<div class="hidden sm:block">Image</div>
<div class="hidden md:block" [mat-sort-header]="'category_name'">Category</div>
<div class="hidden lg:block" [mat-sort-header]="'brand_name'">Brand</div>
<div class="hidden xl:block" [mat-sort-header]="'branch_name'">Branch</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block">Created By</div>
<div class="text-center">Actions</div>
</div>
<!-- New SubCategory Form Row -->
@if (selectedSubCategory && !selectedSubCategory.id) {
<div class="subcategory-grid grid items-center gap-4 border-b bg-blue-50 px-6 py-4 md:px-8">
<div class="truncate"><span class="text-xs font-medium text-blue-600">Auto-generated</span></div>
<div class="truncate font-medium text-blue-600">New SubCategory</div>
<div class="hidden sm:block text-blue-600 text-sm">-</div>
<div class="hidden md:block text-blue-600 text-sm">-</div>
<div class="hidden lg:block text-blue-600 text-sm">-</div>
<div class="hidden xl:block text-blue-600 text-sm">-</div>
<!-- ⭐ NEW: Created By (Auto) -->
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>Auto</span>
</div>
<div class="flex items-center justify-center gap-2">
<button mat-icon-button (click)="closeDetails()" matTooltip="Close">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:x-mark'"></mat-icon>
</button>
</div>
</div>
<div class="grid">
<ng-container
*ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: selectedSubCategory }"></ng-container>
</div>
}
<!-- Existing SubCategories -->
@for (subCategory of subCategories; track trackByFn($index, subCategory)) {
<div class="subcategory-grid grid items-center gap-4 border-b px-6 py-4 hover:bg-gray-50 transition-colors md:px-8">
<div class="truncate">
<span class="text-xs font-mono text-blue-600 font-semibold">{{ subCategory.sub_category_id }}</span>
</div>
<div class="truncate">
<div class="font-semibold text-gray-900">{{ subCategory.name }}</div>
</div>
<div class="hidden sm:flex items-center">
@if (subCategory.image) {
<img [src]="getFullImageUrl(subCategory.image)" [alt]="subCategory.name"
class="h-10 w-10 rounded object-cover border">
} @else {
<div class="h-10 w-10 rounded bg-gray-100 flex items-center justify-center border">
<mat-icon class="icon-size-5 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
</div>
}
</div>
<div class="hidden md:block truncate">
<div class="text-sm text-gray-600">{{ subCategory.category_name }}</div>
<div class="text-xs text-gray-400">{{ subCategory.category_id }}</div>
</div>
<div class="hidden lg:block truncate">
<div class="text-sm text-gray-600">{{ subCategory.brand_name }}</div>
<div class="text-xs text-gray-400">{{ subCategory.brand_id }}</div>
</div>
<div class="hidden xl:block truncate">
<div class="text-sm text-gray-600">{{ subCategory.branch_name }}</div>
<div class="text-xs text-gray-400">{{ subCategory.branch_id }}</div>
</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block flex items-center gap-1">
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>{{ subCategory.created_by_username || 'N/A' }}</span>
</div>
<div class="flex items-center justify-center gap-1">
<button mat-icon-button (click)="toggleDetails(subCategory.id)"
[matTooltip]="selectedSubCategory?.id === subCategory.id ? 'Hide Details' : 'View Details'">
<mat-icon class="icon-size-5 text-gray-600" [svgIcon]="
selectedSubCategory?.id === subCategory.id
? 'heroicons_outline:chevron-up'
: 'heroicons_outline:chevron-down'
"></mat-icon>
</button>
<button mat-icon-button (click)="editSubCategory(subCategory)" matTooltip="Edit">
<mat-icon class="icon-size-5 text-blue-600" [svgIcon]="'heroicons_outline:pencil'"></mat-icon>
</button>
<button mat-icon-button (click)="deleteSubCategory(subCategory)" matTooltip="Delete">
<mat-icon class="icon-size-5 text-red-600" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
</button>
</div>
</div>
@if (selectedSubCategory?.id === subCategory.id) {
<div class="grid">
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: { $implicit: subCategory }"></ng-container>
</div>
}
}
</div>
<mat-paginator
class="z-10 border-b bg-gray-50 dark:bg-transparent sm:absolute sm:inset-x-0 sm:bottom-0 sm:border-b-0 sm:border-t"
[ngClass]="{ 'pointer-events-none': isLoading }" [length]="pagination.length"
[pageIndex]="pagination.page" [pageSize]="pagination.size" [pageSizeOptions]="[5, 10, 25, 100]"
[showFirstLastButtons]="true"></mat-paginator>
} @else {
<div class="flex flex-col items-center justify-center p-16">
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100 mb-6">
<mat-icon class="icon-size-16 text-gray-400" [svgIcon]="'heroicons_outline:rectangle-group'"></mat-icon>
</div>
<h3 class="text-2xl font-semibold text-gray-900 mb-2">No sub-categories found</h3>
<p class="text-sm text-gray-500 mb-6">Get started by creating your first sub-category</p>
<button mat-flat-button [color]="'primary'" (click)="createSubCategory()">
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
<span class="ml-2">Add SubCategory</span>
</button>
</div>
}
}
<!-- Row Details Template -->
<ng-template #rowDetailsTemplate let-subCategory>
<div class="overflow-hidden bg-white border-b">
<div class="flex">
<form class="flex w-full flex-col" [formGroup]="selectedSubCategoryForm">
<div class="p-8">
<div class="mb-6 pb-6 border-b">
<h2 class="text-xl font-semibold text-gray-900">
{{ subCategory.id ? 'Edit SubCategory' : 'Create New SubCategory' }}
</h2>
@if (subCategory.sub_category_id) {
<p class="text-sm text-gray-500 mt-1">SubCategory ID: <span class="font-mono text-blue-600">{{ subCategory.sub_category_id }}</span></p>
} @else {
<p class="text-sm text-gray-500 mt-1">SubCategory ID will be auto-generated from name</p>
}
</div>
<!-- 4-Level Cascading Form -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- SubCategory Name -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>SubCategory Name</mat-label>
<input matInput [formControlName]="'name'" placeholder="e.g., Diet Coke"
(input)="onNameChange($event)" />
<mat-error *ngIf="selectedSubCategoryForm.get('name')?.hasError('required')">
Name is required
</mat-error>
<mat-hint>ID will be: {{ previewSubCategoryId }}</mat-hint>
</mat-form-field>
<!-- Level 1: Branch Selection -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch</mat-label>
<mat-select [formControlName]="'branch_id'" (selectionChange)="onBranchChange($event)">
<mat-option *ngFor="let branch of branches" [value]="branch.branch_id">
{{ branch.name }} ({{ branch.code }})
</mat-option>
</mat-select>
<mat-error *ngIf="selectedSubCategoryForm.get('branch_id')?.hasError('required')">
Branch is required
</mat-error>
</mat-form-field>
<!-- Branch ID (auto-fill) -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch ID</mat-label>
<input matInput [value]="selectedBranchId" readonly />
<mat-hint>Auto-filled from branch selection</mat-hint>
</mat-form-field>
<!-- Branch Name (auto-fill) -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Branch Name</mat-label>
<input matInput [value]="selectedBranchName" readonly />
<mat-hint>Auto-filled from branch selection</mat-hint>
</mat-form-field>
<!-- Level 2: Brand Selection -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Brand</mat-label>
<mat-select [formControlName]="'brand_id'" (selectionChange)="onBrandChange($event)"
[disabled]="!selectedBranchId">
@if (!selectedBranchId) {
<mat-option disabled>Select branch first</mat-option>
}
<mat-option *ngFor="let brand of filteredBrands" [value]="brand.brand_id">
{{ brand.name }}
</mat-option>
</mat-select>
<mat-error *ngIf="selectedSubCategoryForm.get('brand_id')?.hasError('required')">
Brand is required
</mat-error>
<mat-hint>Brands filtered by selected branch</mat-hint>
</mat-form-field>
<!-- Brand ID (auto-fill) -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Brand ID</mat-label>
<input matInput [value]="selectedBrandId" readonly />
<mat-hint>Auto-filled from brand selection</mat-hint>
</mat-form-field>
<!-- Level 3: Category Selection -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Category</mat-label>
<mat-select [formControlName]="'category_id'" (selectionChange)="onCategoryChange($event)"
[disabled]="!selectedBrandId">
@if (!selectedBrandId) {
<mat-option disabled>Select brand first</mat-option>
}
<mat-option *ngFor="let category of filteredCategories" [value]="category.category_id">
{{ category.name }}
</mat-option>
</mat-select>
<mat-error *ngIf="selectedSubCategoryForm.get('category_id')?.hasError('required')">
Category is required
</mat-error>
<mat-hint>Categories filtered by selected brand</mat-hint>
</mat-form-field>
<!-- Category ID (auto-fill) -->
<mat-form-field class="w-full" appearance="outline">
<mat-label>Category ID</mat-label>
<input matInput [value]="selectedCategoryId" readonly />
<mat-hint>Auto-filled from category selection</mat-hint>
</mat-form-field>
<!-- Category Name (auto-fill) -->
<mat-form-field class="w-full md:col-span-2" appearance="outline">
<mat-label>Category Name</mat-label>
<input matInput [value]="selectedCategoryName" readonly />
<mat-hint>Auto-filled from category selection</mat-hint>
</mat-form-field>
<!-- ⭐ NEW: Created By Field (Read-only) -->
@if (subCategory.id && subCategory.created_by_username) {
<mat-form-field class="w-full" appearance="outline">
<mat-label>Created By</mat-label>
<input
matInput
[value]="subCategory.created_by_username"
readonly
/>
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
</mat-form-field>
}
<!-- SubCategory Image Upload -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">SubCategory Image (Optional)</label>
<div class="flex items-center gap-4">
<div class="flex-shrink-0">
@if (imagePreview || subCategory.image) {
<img [src]="imagePreview || getFullImageUrl(subCategory.image)" [alt]="subCategory.name"
class="h-24 w-24 rounded-lg object-cover border-2 border-gray-200">
} @else {
<div class="h-24 w-24 rounded-lg bg-gray-100 flex items-center justify-center border-2 border-dashed border-gray-300">
<mat-icon class="icon-size-8 text-gray-400" [svgIcon]="'heroicons_outline:photo'"></mat-icon>
</div>
}
</div>
<div class="flex-1">
<label class="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<mat-icon class="icon-size-5 mr-2" [svgIcon]="'heroicons_outline:cloud-arrow-up'"></mat-icon>
Upload Image
<input type="file" accept="image/*" class="hidden" (change)="onImageSelected($event)">
</label>
<p class="mt-2 text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
@if (imagePreview || subCategory.image) {
<button type="button" mat-button color="warn" class="mt-2" (click)="removeImage()">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
Remove Image
</button>
}
</div>
</div>
</div>
</div>
@if (subCategory.id) {
<div class="mt-6 pt-6 border-t">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Created:</span>
<span class="ml-2 text-gray-900">{{ subCategory.created_at | date:'medium' }}</span>
</div>
@if (subCategory.updated_at && subCategory.updated_at !== subCategory.created_at) {
<div>
<span class="text-gray-500">Last Updated:</span>
<span class="ml-2 text-gray-900">{{ subCategory.updated_at | date:'medium' }}</span>
</div>
}
</div>
</div>
}
</div>
<!-- Action Buttons -->
<div class="flex w-full items-center justify-between border-t bg-gray-50 px-8 py-4">
<button mat-button [color]="'warn'" (click)="deleteSelectedSubCategory()"
type="button" [disabled]="!subCategory.id">
Delete
</button>
<div class="flex items-center gap-3">
@if (flashMessage) {
<div class="flex items-center text-sm">
@if (flashMessage === 'success') {
<mat-icon class="icon-size-5 text-green-600 mr-2"
[svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
<span class="text-green-600">{{ subCategory.id ? 'Updated' : 'Created' }} successfully</span>
}
@if (flashMessage === 'error') {
<mat-icon class="icon-size-5 text-red-600 mr-2"
[svgIcon]="'heroicons_outline:x-circle'"></mat-icon>
<span class="text-red-600">Error occurred</span>
}
</div>
}
<button mat-stroked-button (click)="closeDetails()" type="button">
Cancel
</button>
<button mat-flat-button [color]="'primary'" (click)="updateSelectedSubCategory()"
type="button" [disabled]="selectedSubCategoryForm.invalid || isLoading">
@if (isLoading) {
<mat-icon class="icon-size-5 animate-spin mr-2" [svgIcon]="'heroicons_outline:arrow-path'"></mat-icon>
}
<span>{{ subCategory.id ? 'Update' : 'Create' }}</span>
</button>
</div>
</div>
</form>
</div>
</div>
</ng-template>
</div>
</div>
</div>

View File

@ -0,0 +1,180 @@
/* SubCategory grid layout - NOW 8 COLUMNS WITH CREATED_BY */
.subcategory-grid {
// SubCategory ID, Name, Image, Category, Brand, Branch, Created By, Actions (8 columns)
grid-template-columns: 120px 1fr 80px 150px 150px 150px 150px 120px;
@media (max-width: 1279px) { // xl breakpoint - hide Created By (changed from 2xl)
grid-template-columns: 120px 1fr 80px 150px 150px 150px 120px;
}
@media (max-width: 1279px) { // xl - hide branch
grid-template-columns: 120px 1fr 80px 150px 150px 120px;
}
@media (max-width: 1023px) { // lg - hide brand
grid-template-columns: 120px 1fr 80px 150px 120px;
}
@media (max-width: 767px) { // md - hide category
grid-template-columns: 120px 1fr 80px 120px;
}
@media (max-width: 639px) { // sm - hide image
grid-template-columns: 120px 1fr 80px;
}
}
/* Created By Column Styling */
.subcategory-grid > div:nth-child(7) {
display: flex;
align-items: center;
gap: 6px;
}
.subcategory-grid > div:nth-child(7) mat-icon {
color: #6b7280;
font-size: 16px;
width: 16px;
height: 16px;
}
.subcategory-grid > div:nth-child(7) span {
font-size: 13px;
color: #374151;
}
.dark .subcategory-grid > div:nth-child(7) mat-icon {
color: #9ca3af;
}
.dark .subcategory-grid > div:nth-child(7) span {
color: #d1d5db;
}
.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-5 { width: 20px; height: 20px; font-size: 20px; }
.icon-size-8 { width: 32px; height: 32px; font-size: 32px; }
.icon-size-16 { width: 64px; height: 64px; font-size: 64px; }
mat-icon-button {
width: 36px;
height: 36px;
mat-icon { transition: all 0.2s ease; }
&:hover mat-icon { transform: scale(1.1); }
}
mat-form-field {
&.mat-mdc-form-field { width: 100%; }
.mat-mdc-text-field-wrapper { background-color: #ffffff; }
}
.subcategory-grid > div:not(.sticky):not(.bg-blue-50) {
transition: background-color 0.15s ease;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.subcategory-grid { gap: 8px; padding: 12px 16px; }
}
@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);
}
.subcategory-grid > div:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
}
::-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; }
.mat-mdc-form-field.mat-focused .mat-mdc-form-field-flex {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
* {
transition-property: background-color, border-color, color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
mat-paginator { background-color: transparent; }
.font-mono { letter-spacing: 0.05em; }
.mat-mdc-form-field-appearance-outline {
.mat-mdc-form-field-outline { color: #e5e7eb; }
&.mat-focused .mat-mdc-form-field-outline { color: #3b82f6; }
}
.mat-mdc-form-field-hint { font-size: 12px; color: #6b7280; }
.mat-mdc-form-field-error { font-size: 12px; }
.mat-mdc-raised-button, .mat-mdc-outlined-button {
height: 40px;
line-height: 40px;
}
.border-b, .border-t { border-color: #e5e7eb; }
h2 { font-weight: 600; line-height: 1.2; }
.overflow-hidden { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); }
.flex.gap-1 button:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.transition-colors {
transition-property: background-color, color;
transition-duration: 200ms;
}
.text-blue-600 {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
mat-progress-bar { height: 2px; }
.mat-mdc-tooltip { font-size: 12px; padding: 6px 12px; }
@media (max-width: 640px) {
.text-4xl { font-size: 1.875rem; }
}
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.snackbar-success { background-color: #10b981; }
.snackbar-error { background-color: #ef4444; }

View File

@ -1,12 +1,401 @@
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 { 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 { MatSelectModule } from '@angular/material/select';
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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { BehaviorSubject, Observable, Subject, debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs';
import { SubCategoryService, SubCategory } from './subcategory.service';
import { CategoryService, Category } from '../categories/category.service';
import { BrandService, Brand } from '../brand/brand.service';
import { BranchService, Branch } from '../../company/branch-list/branch.service';
@Component({
selector: 'app-sub-categories',
standalone: true,
imports: [],
imports: [
CommonModule, ReactiveFormsModule, MatPaginatorModule, MatSortModule,
MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule,
MatIconModule, MatTooltipModule, MatProgressBarModule, MatSnackBarModule
],
templateUrl: './sub-categories.component.html',
styleUrl: './sub-categories.component.scss'
})
export class SubCategoriesComponent {
export class SubCategoriesComponent implements OnInit, OnDestroy {
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
}
subCategories$: Observable<SubCategory[]>;
private subCategoriesSubject = new BehaviorSubject<SubCategory[]>([]);
private destroy$ = new Subject<void>();
searchInputControl = new FormControl('');
selectedSubCategoryForm!: FormGroup;
selectedSubCategory: SubCategory | null = null;
isLoading = false;
flashMessage: 'success' | 'error' | null = null;
pagination = { length: 0, page: 0, size: 10 };
// All data
branches: Branch[] = [];
brands: Brand[] = [];
categories: Category[] = [];
// Filtered data for cascading
filteredBrands: Brand[] = [];
filteredCategories: Category[] = [];
// Auto-fill values
selectedBranchId = '';
selectedBranchName = '';
selectedBrandId = '';
selectedCategoryId = '';
selectedCategoryName = '';
previewSubCategoryId = 'XXX0001';
// Image handling
imagePreview: string | null = null;
selectedImageFile: File | null = null;
constructor(
private fb: FormBuilder,
private subCategoryService: SubCategoryService,
private categoryService: CategoryService,
private brandService: BrandService,
private branchService: BranchService,
private snackBar: MatSnackBar
) {
this.subCategories$ = this.subCategoriesSubject.asObservable();
this.initializeForm();
}
ngOnInit(): void {
this.loadBranches();
this.loadBrands();
this.loadCategories();
this.loadSubCategories();
this.setupSearch();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private initializeForm(): void {
this.selectedSubCategoryForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
branch_id: ['', [Validators.required]],
brand_id: ['', [Validators.required]],
category_id: ['', [Validators.required]]
});
}
private setupSearch(): void {
this.searchInputControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
).subscribe((searchTerm) => {
this.filterSubCategories(searchTerm || '');
});
}
loadBranches(): void {
this.branchService.getAllBranches().pipe(takeUntil(this.destroy$))
.subscribe({
next: (branches) => { this.branches = branches; },
error: (error) => this.showSnackBar('Failed to load branches', 'error')
});
}
loadBrands(): void {
this.brandService.getAllBrands().pipe(takeUntil(this.destroy$))
.subscribe({
next: (brands) => { this.brands = brands; },
error: (error) => this.showSnackBar('Failed to load brands', 'error')
});
}
loadCategories(): void {
this.categoryService.getAllCategories().pipe(takeUntil(this.destroy$))
.subscribe({
next: (categories) => { this.categories = categories; },
error: (error) => this.showSnackBar('Failed to load categories', 'error')
});
}
loadSubCategories(): void {
this.isLoading = true;
this.subCategoryService.getAllSubCategories().pipe(takeUntil(this.destroy$))
.subscribe({
next: (subCategories) => {
this.subCategoriesSubject.next(subCategories);
this.pagination.length = subCategories.length;
this.isLoading = false;
},
error: (error) => {
this.showSnackBar('Failed to load sub-categories', 'error');
this.isLoading = false;
}
});
}
private filterSubCategories(searchTerm: string): void {
this.subCategoryService.getAllSubCategories().pipe(
map(subCategories => {
if (!searchTerm) return subCategories;
const term = searchTerm.toLowerCase();
return subCategories.filter(sub =>
sub.name.toLowerCase().includes(term) ||
sub.sub_category_id.toLowerCase().includes(term) ||
sub.category_name.toLowerCase().includes(term) ||
sub.brand_name.toLowerCase().includes(term)
);
}),
takeUntil(this.destroy$)
).subscribe(filtered => {
this.subCategoriesSubject.next(filtered);
this.pagination.length = filtered.length;
});
}
onNameChange(event: any): void {
const name = event.target.value;
this.previewSubCategoryId = name ? this.generateSubCategoryIdPreview(name) : 'XXX0001';
}
private generateSubCategoryIdPreview(name: string): string {
const lettersOnly = name.replace(/[^a-zA-Z]/g, '').toUpperCase();
const prefix = lettersOnly.substring(0, 3).padEnd(3, 'X');
return `${prefix}0001`;
}
// Level 1: Branch Selection
onBranchChange(event: any): void {
const branchId = event.value;
const branch = this.branches.find(b => b.branch_id === branchId);
if (branch) {
this.selectedBranchId = branch.branch_id;
this.selectedBranchName = branch.name;
// Filter brands by branch
this.filteredBrands = this.brands.filter(b => b.branch_id === branchId);
// Reset downstream selections
this.selectedSubCategoryForm.patchValue({ brand_id: '', category_id: '' });
this.selectedBrandId = '';
this.selectedCategoryId = '';
this.selectedCategoryName = '';
this.filteredCategories = [];
}
}
// Level 2: Brand Selection
onBrandChange(event: any): void {
const brandId = event.value;
const brand = this.brands.find(b => b.brand_id === brandId);
if (brand) {
this.selectedBrandId = brand.brand_id;
// Filter categories by brand
this.filteredCategories = this.categories.filter(c => c.brand_id === brandId);
// Reset downstream selections
this.selectedSubCategoryForm.patchValue({ category_id: '' });
this.selectedCategoryId = '';
this.selectedCategoryName = '';
}
}
// Level 3: Category Selection
onCategoryChange(event: any): void {
const categoryId = event.value;
const category = this.categories.find(c => c.category_id === categoryId);
if (category) {
this.selectedCategoryId = category.category_id;
this.selectedCategoryName = category.name;
}
}
onImageSelected(event: any): void {
const file = event.target.files[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
this.showSnackBar('Image size must be less than 5MB', 'error');
return;
}
if (!file.type.startsWith('image/')) {
this.showSnackBar('Please select an image file', 'error');
return;
}
this.selectedImageFile = file;
const reader = new FileReader();
reader.onload = (e: any) => { this.imagePreview = e.target.result; };
reader.readAsDataURL(file);
}
}
removeImage(): void {
this.imagePreview = null;
this.selectedImageFile = null;
if (this.selectedSubCategory?.id) this.selectedSubCategory.image = null;
}
getFullImageUrl(imagePath: string | null): string {
if (!imagePath) return '';
if (imagePath.startsWith('http')) return imagePath;
return `http://localhost:5000${imagePath}`;
}
createSubCategory(): void {
this.selectedSubCategory = {
id: 0, sub_category_id: '', name: '', image: null,
category_id: '', category_name: '', brand_id: '', brand_name: '',
branch_id: '', branch_name: '', created_at: '', updated_at: ''
};
this.selectedSubCategoryForm.reset();
this.selectedBranchId = '';
this.selectedBranchName = '';
this.selectedBrandId = '';
this.selectedCategoryId = '';
this.selectedCategoryName = '';
this.filteredBrands = [];
this.filteredCategories = [];
this.previewSubCategoryId = 'XXX0001';
this.imagePreview = null;
this.selectedImageFile = null;
this.flashMessage = null;
}
editSubCategory(subCategory: SubCategory): void {
this.selectedSubCategory = { ...subCategory };
this.selectedSubCategoryForm.patchValue({
name: subCategory.name,
branch_id: subCategory.branch_id,
brand_id: subCategory.brand_id,
category_id: subCategory.category_id
});
this.selectedBranchId = subCategory.branch_id;
this.selectedBranchName = subCategory.branch_name;
this.selectedBrandId = subCategory.brand_id;
this.selectedCategoryId = subCategory.category_id;
this.selectedCategoryName = subCategory.category_name;
this.filteredBrands = this.brands.filter(b => b.branch_id === subCategory.branch_id);
this.filteredCategories = this.categories.filter(c => c.brand_id === subCategory.brand_id);
this.previewSubCategoryId = subCategory.sub_category_id;
this.imagePreview = subCategory.image ? this.getFullImageUrl(subCategory.image) : null;
this.selectedImageFile = null;
this.flashMessage = null;
}
toggleDetails(subCategoryId: number): void {
if (this.selectedSubCategory?.id === subCategoryId) {
this.closeDetails();
} else {
const subCategory = this.subCategoriesSubject.value.find(s => s.id === subCategoryId);
if (subCategory) this.editSubCategory(subCategory);
}
}
closeDetails(): void {
this.selectedSubCategory = null;
this.selectedSubCategoryForm.reset();
this.selectedBranchId = '';
this.selectedBranchName = '';
this.selectedBrandId = '';
this.selectedCategoryId = '';
this.selectedCategoryName = '';
this.filteredBrands = [];
this.filteredCategories = [];
this.previewSubCategoryId = 'XXX0001';
this.imagePreview = null;
this.selectedImageFile = null;
this.flashMessage = null;
}
updateSelectedSubCategory(): void {
if (this.selectedSubCategoryForm.invalid) {
this.selectedSubCategoryForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formData = new FormData();
formData.append('name', this.selectedSubCategoryForm.get('name')?.value);
formData.append('branch_id', this.selectedSubCategoryForm.get('branch_id')?.value);
formData.append('brand_id', this.selectedSubCategoryForm.get('brand_id')?.value);
formData.append('category_id', this.selectedSubCategoryForm.get('category_id')?.value);
if (this.selectedImageFile) formData.append('image', this.selectedImageFile);
const operation = this.selectedSubCategory?.id
? this.subCategoryService.updateSubCategory(this.selectedSubCategory.id, formData)
: this.subCategoryService.createSubCategory(formData);
operation.pipe(takeUntil(this.destroy$)).subscribe({
next: (subCategory) => {
this.flashMessage = 'success';
this.isLoading = false;
this.showSnackBar(
this.selectedSubCategory?.id ? 'SubCategory updated successfully' : 'SubCategory created successfully',
'success'
);
setTimeout(() => {
this.closeDetails();
this.loadSubCategories();
}, 1500);
},
error: (error) => {
this.flashMessage = 'error';
this.isLoading = false;
this.showSnackBar(error.error?.error || 'Failed to save sub-category', 'error');
}
});
}
deleteSelectedSubCategory(): void {
if (!this.selectedSubCategory?.id) return;
if (!confirm(`Are you sure you want to delete sub-category "${this.selectedSubCategory.name}"?`)) return;
this.deleteSubCategory(this.selectedSubCategory);
}
deleteSubCategory(subCategory: SubCategory): void {
if (!confirm(`Are you sure you want to delete sub-category "${subCategory.name}"?`)) return;
this.isLoading = true;
this.subCategoryService.deleteSubCategory(subCategory.id).pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.showSnackBar('SubCategory deleted successfully', 'success');
this.closeDetails();
this.loadSubCategories();
},
error: (error) => {
this.showSnackBar(error.error?.error || 'Failed to delete sub-category', 'error');
this.isLoading = false;
}
});
}
trackByFn(index: number, item: SubCategory): number {
return item.id;
}
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'
});
}
}

View File

@ -0,0 +1,94 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from '../../../../../../environments/environment';
export interface SubCategory {
id: number;
sub_category_id: string;
name: string;
image: string | null;
category_id: string;
category_name: string;
brand_id: string;
brand_name: string;
branch_id: string;
branch_name: string;
created_at: string;
updated_at: string;
// ⭐ NEW: Created by fields (automatic from backend)
created_by?: number;
created_by_username?: string;
}
@Injectable({
providedIn: 'root'
})
export class SubCategoryService {
private apiUrl = `${environment.apiUrl}/subcategories`;
constructor(private http: HttpClient) {}
private getHeaders(): HttpHeaders {
const token = localStorage.getItem('accessToken');
return new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
});
}
getAllSubCategories(): Observable<SubCategory[]> {
return this.http.get<SubCategory[]>(this.apiUrl, { headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
getSubCategory(id: number): Observable<SubCategory> {
return this.http.get<SubCategory>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
/**
* Create new subcategory (with FormData for image upload)
* ⭐ NOTE: Don't add created_by to FormData
* Backend automatically extracts it from JWT token
*/
createSubCategory(formData: FormData): Observable<SubCategory> {
const token = localStorage.getItem('accessToken');
const headers = new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
});
return this.http.post<SubCategory>(this.apiUrl, formData, { headers })
.pipe(catchError(this.handleError));
}
/**
* Update existing subcategory (with FormData for image upload)
* ⭐ NOTE: Don't update created_by
* It's set once during creation and shouldn't change
*/
updateSubCategory(id: number, formData: FormData): Observable<SubCategory> {
const token = localStorage.getItem('accessToken');
const headers = new HttpHeaders({
'Authorization': token ? `Bearer ${token}` : ''
});
return this.http.put<SubCategory>(`${this.apiUrl}/${id}`, formData, { headers })
.pipe(catchError(this.handleError));
}
deleteSubCategory(id: number): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`, { headers: this.getHeaders() })
.pipe(catchError(this.handleError));
}
private handleError(error: any): Observable<never> {
console.error('API Error:', error);
let errorMessage = 'An error occurred';
if (error.error instanceof ErrorEvent) {
errorMessage = error.error.message;
} else {
errorMessage = error.error?.error || error.message || 'Server error';
}
return throwError(() => ({ error: errorMessage, status: error.status }));
}
}

View File

@ -1,9 +1,11 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { AuthService } from 'app/core/auth/auth.service';
import { RoleStateService } from 'app/core/services/role-state.service';
import { environment } from '@environments/environment';
import { Subject, takeUntil } from 'rxjs';
interface Role {
id: number;
@ -28,7 +30,7 @@ interface Permission {
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule]
})
export class RoleManagementComponent implements OnInit {
export class RoleManagementComponent implements OnInit, OnDestroy {
roles: Role[] = [];
filteredRoles: Role[] = [];
roleForm: FormGroup;
@ -45,11 +47,13 @@ export class RoleManagementComponent implements OnInit {
permissionMap: { [key: string]: Permission } = {};
private apiUrl = environment.apiUrl;
private destroy$ = new Subject<void>();
constructor(
private fb: FormBuilder,
private http: HttpClient,
private authService: AuthService
private authService: AuthService,
private roleStateService: RoleStateService
) {
this.roleForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
@ -63,6 +67,11 @@ export class RoleManagementComponent implements OnInit {
this.loadRoles();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private getHeaders(): HttpHeaders {
const token = this.authService.accessToken;
return new HttpHeaders({
@ -80,6 +89,7 @@ export class RoleManagementComponent implements OnInit {
permissions.forEach(p => {
this.permissionMap[p.id] = p;
});
console.log('✓ Permissions loaded:', permissions.length);
},
error: (error) => {
console.error('Error loading permissions:', error);
@ -100,6 +110,7 @@ export class RoleManagementComponent implements OnInit {
this.roles = roles;
this.filteredRoles = [...roles];
this.isLoading = false;
console.log('✓ Roles loaded:', roles.length);
},
error: (error) => {
console.error('Error loading roles:', error);
@ -124,6 +135,7 @@ export class RoleManagementComponent implements OnInit {
onSubmit(): void {
if (this.roleForm.invalid) {
this.showError('Please fill in all required fields correctly');
return;
}
@ -138,15 +150,30 @@ export class RoleManagementComponent implements OnInit {
{ headers: this.getHeaders() }
).subscribe({
next: (updatedRole) => {
// Update role in local arrays
const index = this.roles.findIndex(r => r.id === this.currentRoleId);
if (index !== -1) {
this.roles[index] = updatedRole;
}
this.filteredRoles = [...this.roles];
const filteredIndex = this.filteredRoles.findIndex(r => r.id === this.currentRoleId);
if (filteredIndex !== -1) {
this.filteredRoles[filteredIndex] = updatedRole;
}
// ⭐ Notify other components about role update
this.roleStateService.notifyRoleUpdate(
updatedRole.id,
updatedRole.name,
updatedRole.permissions
);
this.showSuccess('Role updated successfully');
this.resetForm();
this.showAddRoleForm = false;
this.isLoading = false;
console.log('✓ Role updated:', updatedRole);
},
error: (error) => {
console.error('Error updating role:', error);
@ -162,12 +189,23 @@ export class RoleManagementComponent implements OnInit {
{ headers: this.getHeaders() }
).subscribe({
next: (newRole) => {
// Add new role to arrays
this.roles.push(newRole);
this.filteredRoles = [...this.roles];
// ⭐ Notify other components about new role
this.roleStateService.notifyRoleCreated(
newRole.id,
newRole.name,
newRole.permissions
);
this.showSuccess('Role created successfully');
this.resetForm();
this.showAddRoleForm = false;
this.isLoading = false;
console.log('✓ Role created:', newRole);
},
error: (error) => {
console.error('Error creating role:', error);
@ -184,13 +222,18 @@ export class RoleManagementComponent implements OnInit {
this.roleForm.patchValue({
name: role.name,
description: role.description,
permissions: role.permissions
permissions: [...role.permissions]
});
this.showAddRoleForm = true;
console.log('Editing role:', role.name, 'Permissions:', role.permissions);
}
deleteRole(id: number): void {
if (!confirm('Are you sure you want to delete this role? This action cannot be undone.')) {
const role = this.roles.find(r => r.id === id);
if (!role) return;
if (!confirm(`Are you sure you want to delete the "${role.name}" role? This action cannot be undone.`)) {
return;
}
@ -198,10 +241,17 @@ export class RoleManagementComponent implements OnInit {
this.http.delete(`${this.apiUrl}/roles/${id}`, { headers: this.getHeaders() })
.subscribe({
next: () => {
// Remove from both arrays
this.roles = this.roles.filter(r => r.id !== id);
this.filteredRoles = this.filteredRoles.filter(r => r.id !== id);
// ⭐ Notify other components about role deletion
this.roleStateService.notifyRoleDeleted(id);
this.showSuccess('Role deleted successfully');
this.isLoading = false;
console.log('✓ Role deleted, ID:', id);
},
error: (error) => {
console.error('Error deleting role:', error);
@ -254,6 +304,8 @@ export class RoleManagementComponent implements OnInit {
const newPermissions = currentPermissions.filter(id => !modulePermissionIds.includes(id));
this.roleForm.get('permissions')?.setValue(newPermissions);
}
this.roleForm.get('permissions')?.markAsDirty();
}
isModuleFullySelected(module: string): boolean {
@ -262,7 +314,8 @@ export class RoleManagementComponent implements OnInit {
.map(p => p.id);
const currentPermissions = this.roleForm.get('permissions')?.value || [];
return modulePermissionIds.every(id => currentPermissions.includes(id));
return modulePermissionIds.length > 0 &&
modulePermissionIds.every(id => currentPermissions.includes(id));
}
getPermissionsByModule(module: string): Permission[] {
@ -286,10 +339,18 @@ export class RoleManagementComponent implements OnInit {
}
this.roleForm.get('permissions')?.setValue(permissions);
this.roleForm.get('permissions')?.markAsDirty();
}
/**
* ⭐ Toggle permission in the table with immediate local update
*/
togglePermission(role: Role, permissionId: string, checked: boolean): void {
console.log(`Toggling permission: ${permissionId} for role: ${role.name}, checked: ${checked}`);
// Create updated permissions array
const updatedPermissions = [...role.permissions];
if (checked) {
if (!updatedPermissions.includes(permissionId)) {
updatedPermissions.push(permissionId);
@ -301,33 +362,69 @@ export class RoleManagementComponent implements OnInit {
}
}
// Update role on backend
// ⭐ IMMEDIATELY update local state for instant UI feedback
const roleIndex = this.roles.findIndex(r => r.id === role.id);
if (roleIndex !== -1) {
this.roles[roleIndex] = {
...this.roles[roleIndex],
permissions: updatedPermissions
};
}
const filteredIndex = this.filteredRoles.findIndex(r => r.id === role.id);
if (filteredIndex !== -1) {
this.filteredRoles[filteredIndex] = {
...this.filteredRoles[filteredIndex],
permissions: updatedPermissions
};
}
// Update backend
this.http.put<Role>(
`${this.apiUrl}/roles/${role.id}`,
{ permissions: updatedPermissions },
{ headers: this.getHeaders() }
).subscribe({
next: (updatedRole) => {
const roleIndex = this.roles.findIndex(r => r.id === role.id);
if (roleIndex !== -1) {
this.roles[roleIndex] = updatedRole;
this.filteredRoles = [...this.roles];
console.log('✓ Permission updated on backend:', updatedRole);
// Update with backend response
const roleIdx = this.roles.findIndex(r => r.id === role.id);
if (roleIdx !== -1) {
this.roles[roleIdx] = updatedRole;
}
const filteredIdx = this.filteredRoles.findIndex(r => r.id === role.id);
if (filteredIdx !== -1) {
this.filteredRoles[filteredIdx] = updatedRole;
}
// ⭐ Notify other components about permission change
this.roleStateService.notifyRoleUpdate(
updatedRole.id,
updatedRole.name,
updatedRole.permissions
);
},
error: (error) => {
console.error('Error updating permissions:', error);
this.showError('Failed to update permissions');
// Rollback on error
this.loadRoles();
}
});
}
private showSuccess(message: string): void {
this.successMessage = message;
this.errorMessage = null;
setTimeout(() => this.successMessage = null, 3000);
}
private showError(message: string): void {
this.errorMessage = message;
this.successMessage = null;
setTimeout(() => this.errorMessage = null, 5000);
}
}

View File

@ -1,4 +1,4 @@
// role.service.ts - NEW SERVICE FOR ROLE MANAGEMENT
// role-management.service.ts - COMPLETE SERVICE
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
@ -16,9 +16,11 @@ export interface Role {
id: number;
name: string;
description: string;
permissions: string[]; // Array of permission IDs
permissions: string[];
created_at: string;
updated_at: string;
created_by?: number;
created_by_username?: string;
}
@Injectable({
@ -33,8 +35,7 @@ export class RoleService {
public roles$ = this._roles.asObservable();
constructor(private http: HttpClient) {
// Load roles on service initialization
this.loadRoles();
console.log('✓ RoleService initialized');
}
/**
@ -44,20 +45,11 @@ export class RoleService {
return this.http.get<Role[]>(this.apiUrl).pipe(
tap(roles => {
this._roles.next(roles);
console.log('✓ Roles cached:', roles.length);
})
);
}
/**
* Load roles and cache them
*/
loadRoles(): void {
this.getRoles().subscribe(
roles => console.log('Roles loaded:', roles.length),
error => console.error('Error loading roles:', error)
);
}
/**
* Get single role by ID
*/
@ -69,27 +61,21 @@ export class RoleService {
* Create new role
*/
createRole(role: Partial<Role>): Observable<Role> {
return this.http.post<Role>(this.apiUrl, role).pipe(
tap(() => this.loadRoles()) // Reload after creation
);
return this.http.post<Role>(this.apiUrl, role);
}
/**
* Update existing role
*/
updateRole(id: number, role: Partial<Role>): Observable<Role> {
return this.http.put<Role>(`${this.apiUrl}/${id}`, role).pipe(
tap(() => this.loadRoles()) // Reload after update
);
return this.http.put<Role>(`${this.apiUrl}/${id}`, role);
}
/**
* Delete role
*/
deleteRole(id: number): Observable<any> {
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
tap(() => this.loadRoles()) // Reload after deletion
);
return this.http.delete(`${this.apiUrl}/${id}`);
}
/**
@ -100,32 +86,7 @@ export class RoleService {
}
/**
* Filter roles based on current user's role
* Maintains the same permission logic
*/
getAvailableRolesForUser(currentUserRole: string): Observable<Role[]> {
return this.roles$.pipe(
tap(roles => {
let filteredRoles: Role[];
if (currentUserRole === 'Client') {
// Clients can only assign Refiller role
filteredRoles = roles.filter(role => role.name === 'Refiller');
} else if (['Management', 'SuperAdmin', 'Admin'].includes(currentUserRole)) {
// Admins can assign any role
filteredRoles = roles;
} else {
// Default - no roles available
filteredRoles = [];
}
console.log(`Available roles for ${currentUserRole}:`, filteredRoles);
})
);
}
/**
* Convert Role to dropdown format
* Convert Role array to dropdown format
*/
rolesToDropdownFormat(roles: Role[]): { value: string; label: string }[] {
return roles.map(role => ({
@ -135,9 +96,23 @@ export class RoleService {
}
/**
* Get role by name
* Get role by name from cached roles
*/
getRoleByName(name: string): Role | undefined {
return this._roles.getValue().find(role => role.name === name);
}
/**
* Get current cached roles
*/
getCachedRoles(): Role[] {
return this._roles.getValue();
}
/**
* Clear cached roles
*/
clearCache(): void {
this._roles.next([]);
}
}

View File

@ -33,7 +33,7 @@
@if (users$ | async; as users) {
@if (users.length > 0 || selectedUser) {
<div class="grid">
<!-- Header -->
<!-- Header - NOW 10 COLUMNS -->
<div class="user-grid text-secondary sticky top-0 z-10 grid gap-4 bg-gray-50 px-6 py-4 text-md font-semibold shadow dark:bg-black dark:bg-opacity-5 md:px-8"
matSort matSortDisableClear>
<div class="hidden md:block" [mat-sort-header]="'id'">ID</div>
@ -42,6 +42,12 @@
<div class="hidden lg:block" [mat-sort-header]="'contact'">Contact</div>
<div class="hidden lg:block" [mat-sort-header]="'roles'">Role</div>
<div class="hidden lg:block" [mat-sort-header]="'user_status'">Status</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block">Created By</div>
<!-- ⭐ NEW: Assigned To Column -->
<div class="hidden xl:block">Assigned To</div>
<!-- ⭐ NEW: Machines Column -->
<div class="hidden xl:block">Machines</div>
<div class="hidden sm:block">Details</div>
</div>
@ -91,6 +97,22 @@
</span>
</div>
<!-- ⭐ NEW: Created By (Auto) -->
<div class="hidden xl:block text-blue-600 flex items-center gap-1">
<mat-icon class="icon-size-4" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>Auto</span>
</div>
<!-- ⭐ NEW: Assigned To -->
<div class="hidden xl:block text-blue-600">
<span class="text-xs">-</span>
</div>
<!-- ⭐ NEW: Machines -->
<div class="hidden xl:block text-blue-600">
<span class="text-xs">-</span>
</div>
<!-- Details button -->
<div class="hidden sm:block">
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button [color]="'primary'"
@ -178,6 +200,40 @@
}
</div>
<!-- ⭐ NEW: Created By Column -->
<div class="hidden xl:block flex items-center gap-1">
<mat-icon class="icon-size-4 text-gray-500" [svgIcon]="'heroicons_outline:user'"></mat-icon>
<span>{{ user.created_by_username || 'N/A' }}</span>
</div>
<!-- ⭐ NEW: Assigned To (Client) Column -->
<div class="hidden xl:block flex items-center gap-1">
@if (user.roles === 'Refiller') {
@if (user.assigned_to_username) {
<mat-icon class="icon-size-4 text-blue-600" [svgIcon]="'heroicons_outline:user-group'"></mat-icon>
<span class="text-blue-600">{{ user.assigned_to_username }}</span>
} @else {
<span class="text-gray-400 text-xs">Not Assigned</span>
}
} @else {
<span class="text-gray-400 text-xs">-</span>
}
</div>
<!-- ⭐ NEW: Machines Count Column -->
<div class="hidden xl:block">
@if (user.roles === 'Refiller' && user.assigned_machines_count && user.assigned_machines_count > 0) {
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<mat-icon class="icon-size-3 mr-1" [svgIcon]="'heroicons_outline:cog'"></mat-icon>
{{ user.assigned_machines_count }} machines
</span>
} @else if (user.roles === 'Refiller') {
<span class="text-gray-400 text-xs">No machines</span>
} @else {
<span class="text-gray-400 text-xs">-</span>
}
</div>
<!-- Details button -->
<div class="hidden sm:block">
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
@ -344,6 +400,33 @@
</mat-form-field>
}
<!-- ⭐ NEW: Created By Field (Read-only) -->
@if (user.id && user.created_by_username) {
<mat-form-field class="w-full">
<mat-label>Created By</mat-label>
<input
matInput
[value]="user.created_by_username"
readonly
/>
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_outline:user'"></mat-icon>
</mat-form-field>
}
<!-- ⭐ NEW: Client Assignment (only for Refillers) -->
@if (showClientAssignment) {
<mat-form-field class="w-full">
<mat-label>Assign to Client</mat-label>
<mat-select [formControlName]="'assigned_to'">
<mat-option [value]="''">Not Assigned</mat-option>
<mat-option *ngFor="let client of availableClients" [value]="client.id">
{{ client.username }} ({{ client.email }})
</mat-option>
</mat-select>
<mat-hint>Select which Client this Refiller works for</mat-hint>
</mat-form-field>
}
@if (user.id) {
<div class="mt-4 rounded-lg bg-gray-50 p-4">
<h4 class="text-sm font-medium text-gray-900">Account Information</h4>
@ -359,6 +442,60 @@
}
</div>
<!-- ⭐ NEW: Machine Assignment Section (only when Refiller is assigned to a Client) -->
@if (showMachineAssignment) {
<div class="flex w-full flex-col mt-6 pt-6 border-t">
<h3 class="mb-4 text-lg font-semibold text-gray-900 flex items-center gap-2">
<mat-icon class="text-blue-600" [svgIcon]="'heroicons_outline:cog'"></mat-icon>
Assign Machines
</h3>
@if (machinesLoading) {
<div class="p-4 text-center">
<mat-icon class="animate-spin text-blue-600">refresh</mat-icon>
<p class="text-sm text-gray-600 mt-2">Loading machines...</p>
</div>
}
@if (!machinesLoading && availableMachines.length === 0) {
<p class="text-sm text-gray-500 p-4 border rounded bg-gray-50">
No machines available for selected client
</p>
}
@if (!machinesLoading && availableMachines.length > 0) {
<mat-form-field class="w-full" appearance="outline">
<mat-label>Select Machines</mat-label>
<mat-select [(value)]="selectedMachineIds" multiple>
<mat-option *ngFor="let machine of availableMachines" [value]="machine.machine_id">
<div class="flex flex-col py-1">
<span class="font-medium">{{ machine.machine_id }} - {{ machine.machine_model }}</span>
<span class="text-xs text-gray-500">{{ machine.branch_name }} • {{ machine.operation_status }}</span>
</div>
</mat-option>
</mat-select>
<mat-hint>Select machines this Refiller will maintain</mat-hint>
</mat-form-field>
<!-- Show currently selected machines -->
@if (selectedMachineIds.length > 0) {
<div class="mt-2">
<span class="text-xs text-gray-600">{{ selectedMachineIds.length }} machine(s) selected</span>
<div class="flex flex-wrap gap-2 mt-1">
<span *ngFor="let machineId of selectedMachineIds" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
{{ machineId }}
<button type="button" (click)="removeMachineId(machineId)"
class="ml-1 hover:bg-blue-200 rounded-full p-0.5">
<mat-icon class="icon-size-3">close</mat-icon>
</button>
</span>
</div>
</div>
}
}
</div>
}
<!-- Documents Section -->
<div class="flex w-full flex-col mt-6 pt-6 border-t">
<h3 class="mb-4 text-lg font-semibold text-gray-900">Documents & Files</h3>

View File

@ -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; }

View File

@ -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;
}

View File

@ -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<User[]>(this.BaseUrl);
}
// ⭐ NEW: Get Clients for assignment dropdown
getClients(): Observable<Client[]> {
return this.http.get<Client[]>(`${this.backendBaseUrl}/clients`);
}
// ⭐ NEW: Get machines for a specific client
getClientMachines(clientId: number): Observable<Machine[]> {
return this.http.get<Machine[]>(`${this.backendBaseUrl}/clients/${clientId}/machines`);
}
// ⭐ NEW: Assign machines to Refiller (POST - for new user)
assignMachinesToRefiller(refillerId: number, machineIds: string[]): Observable<any> {
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<any> {
return this.http.put(`${this.backendBaseUrl}/refillers/${refillerId}/machines`, {
machine_ids: machineIds
});
}
// ⭐ NEW: Delete machine assignment
deleteRefillerMachine(refillerId: number, machineId: string): Observable<any> {
return this.http.delete(`${this.backendBaseUrl}/refillers/${refillerId}/machines/${machineId}`);
}
// ⭐ NEW: Get machines assigned to a Refiller
getRefillerMachines(refillerId: number): Observable<Machine[]> {
return this.http.get<Machine[]>(`${this.backendBaseUrl}/refillers/${refillerId}/machines`);
}
addUser(
user: Partial<User>,
photoFile?: File,
@ -77,6 +141,10 @@ export class UserService {
documentFiles?: DocumentWithMetadata[]
): Observable<User> {
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<User>(this.BaseUrl, formData);
}
@ -89,6 +157,10 @@ export class UserService {
): Observable<User> {
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<User>(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);

View File

@ -103,5 +103,8 @@
}
}
}
},
"cli": {
"analytics": "15dead7e-04a1-4f40-8a32-c1cb1a5d23a0"
}
}