Compare commits
12 Commits
fc4eaaa663
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c71959f19a | |||
| 5ad65c3c59 | |||
| e5c5044ff3 | |||
| 7cfe522512 | |||
| 57a235b710 | |||
| 8710e52b3a | |||
| ef27c01e6b | |||
| d54892503a | |||
| 8e91a4a6a1 | |||
| c40c4fc90f | |||
| b40cbf0f80 | |||
| cfa05fd1fc |
@ -13,7 +13,7 @@ MYSQL_USER=vendinguser
|
||||
MYSQL_PASSWORD=vendingpass
|
||||
MYSQL_DATABASE=vending
|
||||
|
||||
SQLITE_DB_PATH=machines
|
||||
SQLITE_DB_PATH=machines.db
|
||||
|
||||
BREVO_SMTP_EMAIL=smukeshsn2000@gmail.com
|
||||
BREVO_SMTP_KEY=your-brevo-smtp-key
|
||||
|
||||
@ -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)
|
||||
|
||||
BIN
Machine-Backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/models/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/models/__pycache__/models.cpython-314.pyc
Normal 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}"
|
||||
BIN
Machine-Backend/app/routes/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
Machine-Backend/app/routes/__pycache__/routes.cpython-314.pyc
Normal file
|
After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
0
Machine-Backend/backup.sql
Normal file
255
Machine-Backend/backup_db.py
Normal 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()
|
||||
603
Machine-Backend/migration_sqlite.py
Normal 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()
|
||||
18
Machine-Backend/reset_password.py
Normal file
@ -0,0 +1,18 @@
|
||||
from app import create_app, db
|
||||
from app.models.models import User
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
email = "test@example.com"
|
||||
new_password = "test123"
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if not user:
|
||||
print("User not found ❌")
|
||||
else:
|
||||
# Hash password in the way your model expects
|
||||
user.password = generate_password_hash(new_password, method='pbkdf2:sha256')
|
||||
db.session.commit()
|
||||
print(f"Password reset successfully for {email}. Login with: {new_password}")
|
||||
@ -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()
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
# Use Python 3.10 slim image
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python packages with timeout
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
--timeout=100 || pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code - MUST include microservice.py
|
||||
COPY microservice.py .
|
||||
|
||||
ENV FLASK_ENV=production
|
||||
ENV FLASK_APP=microservice.py
|
||||
ENV FLASK_RUN_HOST=0.0.0.0
|
||||
ENV FLASK_RUN_PORT=5001
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
# Use python -m to run the module directly
|
||||
CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5001", "--workers", "2", "--timeout", "120", "microservice:app"]
|
||||
@ -15,7 +15,8 @@ CORS(app,
|
||||
)
|
||||
|
||||
# Configuration - point to your main backend
|
||||
MAIN_BACKEND_URL = "http://127.0.0.1:5000" # 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
|
||||
|
||||
5
Project/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
Flask==3.0.0
|
||||
flask-cors==4.0.0
|
||||
requests==2.31.0
|
||||
gunicorn==21.2.0
|
||||
Werkzeug==3.0.1
|
||||
@ -48,6 +48,35 @@ services:
|
||||
echo '🚀 Starting Gunicorn server...' &&
|
||||
gunicorn --bind 0.0.0.0:5000 --workers 2 --timeout 120 run:app
|
||||
"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
microservice:
|
||||
build:
|
||||
context: ./Project
|
||||
dockerfile: Dockerfile
|
||||
container_name: vending-microservice
|
||||
ports:
|
||||
- "8088:5001"
|
||||
volumes:
|
||||
- ./Project:/app
|
||||
networks:
|
||||
- vending-network
|
||||
environment:
|
||||
- MAIN_BACKEND_URL=https://iotbackend.rootxwire.com
|
||||
- FLASK_ENV=production
|
||||
- PYTHONPATH=/app
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:5001/', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@ -60,6 +89,7 @@ services:
|
||||
- vending-network
|
||||
depends_on:
|
||||
- backend
|
||||
- microservice
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
/* 0. Themes */
|
||||
@use 'themes';
|
||||
|
||||
/* 1. Components */
|
||||
@use 'components/example-viewer';
|
||||
@use 'components/input';
|
||||
|
||||
@ -153,7 +153,7 @@ export const appRoutes: Route[] = [
|
||||
{
|
||||
path: 'role-management',
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['Management', 'SuperAdmin'] },
|
||||
data: { roles: ['Management', 'SuperAdmin', 'Admin'] },
|
||||
loadChildren: () =>import('app/modules/admin/dashboard/role-management/role-management.routes')
|
||||
},
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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: [],
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 }));
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -1,38 +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 = 'http://127.0.0.1:5000/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}`;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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; }
|
||||
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 }));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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([]);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,162 +1,305 @@
|
||||
<div class="bg-card flex min-w-0 flex-auto flex-col dark:bg-transparent sm:absolute sm:inset-0 sm:overflow-hidden">
|
||||
<div class="flex min-w-0 flex-auto flex-col h-full">
|
||||
<div class="flex-auto p-6 sm:p-10 overflow-auto max-h-[calc(100vh-100px)]">
|
||||
<div *ngIf="errorMessage" class="text-red-500 text-center mb-4">
|
||||
{{ errorMessage }}
|
||||
<div class="flex-auto p-6 sm:p-10 overflow-auto">
|
||||
<!-- Error Message -->
|
||||
<div *ngIf="errorMessage" class="bg-red-50 border-l-4 border-red-500 text-red-700 px-4 py-3 rounded mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ errorMessage }}</span>
|
||||
<button (click)="retryFetch()" class="text-red-700 underline hover:text-red-900">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid w-full min-w-0 grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
<!-- Machines -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
||||
{{ machineTitle }}
|
||||
</div>
|
||||
<div class="-mr-3 -mt-2 ml-2">
|
||||
<button mat-icon-button [matMenuTriggerFor]="summaryMenu">
|
||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_mini:ellipsis-vertical'"></mat-icon>
|
||||
|
||||
<!-- Top Metrics Row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Machines Card with Menu -->
|
||||
<div class="bg-blue-600 text-white rounded-lg p-5 shadow relative">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="text-sm font-medium">{{ machineTitle }}</div>
|
||||
<button mat-icon-button [matMenuTriggerFor]="machineMenu" class="text-white -mt-2 -mr-2">
|
||||
<mat-icon class="text-white text-xl">more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #machineMenu="matMenu">
|
||||
<button mat-menu-item (click)="updateMachine('all')">
|
||||
<mat-icon>list</mat-icon>
|
||||
<span>All Machines</span>
|
||||
</button>
|
||||
<mat-menu #summaryMenu="matMenu">
|
||||
<button mat-menu-item (click)="updateMachine('active')">
|
||||
Active Machines
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateMachine('inactive')">
|
||||
Inactive Machines
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateMachine('all')">
|
||||
Reset Machines
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col items-center">
|
||||
<div class="text-7xl font-bold leading-none tracking-tight text-blue-500 sm:text-8xl">
|
||||
{{ machineCount }}
|
||||
</div>
|
||||
<button mat-menu-item (click)="updateMachine('active')">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>Active Machines</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateMachine('inactive')">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
<span>Inactive Machines</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
<div class="text-5xl font-bold">{{ machineCount }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Clients -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
||||
Clients
|
||||
<div class="bg-indigo-600 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-medium mb-3">Clients</div>
|
||||
<div class="text-5xl font-bold">{{ clients }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Machines -->
|
||||
<div class="bg-cyan-500 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-medium mb-3">Active Machines</div>
|
||||
<div class="text-5xl font-bold">{{ activeMachines }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Inactive Machines -->
|
||||
<div class="bg-purple-600 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-medium mb-3">Inactive Machines</div>
|
||||
<div class="text-5xl font-bold">{{ inactiveMachines }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment and Bottom Metrics Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
||||
<!-- Payment Overview - Takes 1 column -->
|
||||
<div class="bg-orange-500 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-semibold mb-4 uppercase tracking-wide">Payment Overview</div>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span>Cash</span>
|
||||
<span class="font-semibold">₹{{ paymentCash | number:'1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span>Cashless</span>
|
||||
<span class="font-semibold">₹{{ paymentCashless | number:'1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-xs">UPI/Card (PhonePe)</span>
|
||||
<span class="font-semibold">₹{{ paymentUPIWalletCard | number:'1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-xs">UPI/Wallet (Paytm)</span>
|
||||
<span class="font-semibold">₹{{ paymentUPIWalletPaytm | number:'1.0-0' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col items-center">
|
||||
<div class="text-7xl font-bold leading-none tracking-tight text-red-500 sm:text-8xl">
|
||||
{{ clients }}
|
||||
<div class="border-t border-white/30 pt-3 space-y-1">
|
||||
<div class="flex justify-between text-xs">
|
||||
<span>Refund</span>
|
||||
<span>₹{{ refund | number:'1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<span>Refund Processed</span>
|
||||
<span>₹{{ refundProcessed | number:'1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-base font-bold pt-2 border-t border-white/30">
|
||||
<span>Total</span>
|
||||
<span>₹{{ paymentTotal | number:'1.0-0' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Company Users -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
||||
Company Users
|
||||
</div>
|
||||
|
||||
<!-- Other Metrics - Takes 2 columns -->
|
||||
<div class="lg:col-span-2 grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<!-- Company Users -->
|
||||
<div class="bg-teal-600 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-medium mb-3">Company Users</div>
|
||||
<div class="text-5xl font-bold">{{ companyUsers }}</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col items-center">
|
||||
<div class="text-7xl font-bold leading-none tracking-tight text-amber-500 sm:text-8xl">
|
||||
{{ companyUsers }}
|
||||
|
||||
<!-- Client Users -->
|
||||
<div class="bg-slate-600 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-medium mb-3">Client Users</div>
|
||||
<div class="text-5xl font-bold">{{ clientUsers }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions -->
|
||||
<div class="bg-green-700 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-medium mb-3">Transactions</div>
|
||||
<div class="text-5xl font-bold">{{ transactions | number }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Sales -->
|
||||
<div class="bg-green-600 text-white rounded-lg p-5 shadow">
|
||||
<div class="text-sm font-medium mb-3">Total Sales</div>
|
||||
<div class="text-4xl font-bold">{{ sales }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Machine Status Panels -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Machine Operation Status -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Machine Operation Status</h3>
|
||||
<mat-icon class="text-blue-600 cursor-pointer">filter_list</mat-icon>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
<div *ngFor="let key of getOperationStatusKeys()" class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-700">{{ key }}</span>
|
||||
<span class="text-lg font-semibold text-gray-900">{{ machineOperationStatus[key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Client User -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
||||
Client User
|
||||
</div>
|
||||
|
||||
<!-- Machine Stock Status -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Machine Stock Status</h3>
|
||||
<mat-icon class="text-blue-600 cursor-pointer">filter_list</mat-icon>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col items-center">
|
||||
<div class="text-7xl font-bold leading-none tracking-tight text-red-500 sm:text-8xl">
|
||||
{{ clientUsers }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Transactions -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
||||
Transactions
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col items-center">
|
||||
<div class="text-7xl font-bold leading-none tracking-tight text-red-500 sm:text-8xl">
|
||||
{{ transactions }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sales -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
||||
Sales
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col items-center">
|
||||
<div class="text-7xl font-bold leading-none tracking-tight text-green-500 sm:text-8xl">
|
||||
{{ sales }}
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div *ngFor="let key of getStockStatusKeys()"
|
||||
class="text-center p-3 rounded-lg"
|
||||
[ngClass]="{
|
||||
'bg-green-100 text-green-800': key === '75 - 100%',
|
||||
'bg-yellow-100 text-yellow-800': key === '50 - 75%',
|
||||
'bg-orange-100 text-orange-800': key === '25 - 50%',
|
||||
'bg-red-100 text-red-800': key === '0 - 25%',
|
||||
'bg-pink-100 text-pink-800': key === 'N/A'
|
||||
}">
|
||||
<div class="text-[10px] font-medium mb-1">{{ key }}</div>
|
||||
<div class="text-2xl font-bold">{{ machineStockStatus[key] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chart Visualizations -->
|
||||
<div class="grid w-full min-w-0 grid-cols-1 gap-6 sm:grid-cols-1 md:grid-cols-2 mt-6">
|
||||
<!-- Bar Chart for Key Metrics -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full">
|
||||
<div class="text-lg font-medium leading-6 tracking-tight mb-4">
|
||||
Key Metrics Overview
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Product Sales Chart -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Product Sales</h3>
|
||||
<button mat-stroked-button [matMenuTriggerFor]="productSalesMenu" class="text-sm border border-blue-300 text-blue-700 rounded-lg px-4 py-2 hover:bg-blue-50 flex items-center gap-2">
|
||||
<mat-icon class="text-lg">schedule</mat-icon>
|
||||
<span class="capitalize font-medium">{{ productSalesTimeRange }}</span>
|
||||
<mat-icon class="text-lg">expand_more</mat-icon>
|
||||
</button>
|
||||
<mat-menu #productSalesMenu="matMenu">
|
||||
<button mat-menu-item (click)="updateProductSalesTimeRange('day')">
|
||||
<mat-icon>today</mat-icon>
|
||||
<span>Daily</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateProductSalesTimeRange('week')">
|
||||
<mat-icon>date_range</mat-icon>
|
||||
<span>Weekly</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateProductSalesTimeRange('month')">
|
||||
<mat-icon>calendar_month</mat-icon>
|
||||
<span>Monthly</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateProductSalesTimeRange('year')">
|
||||
<mat-icon>calendar_today</mat-icon>
|
||||
<span>Yearly</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
<apx-chart
|
||||
[series]="barChartOptions.series"
|
||||
[chart]="barChartOptions.chart"
|
||||
[xaxis]="barChartOptions.xaxis"
|
||||
[colors]="barChartOptions.colors"
|
||||
[plotOptions]="barChartOptions.plotOptions"
|
||||
[dataLabels]="barChartOptions.dataLabels"
|
||||
[tooltip]="barChartOptions.tooltip"
|
||||
[series]="productSalesChartOptions.series"
|
||||
[chart]="productSalesChartOptions.chart"
|
||||
[xaxis]="productSalesChartOptions.xaxis"
|
||||
[yaxis]="productSalesChartOptions.yaxis"
|
||||
[colors]="productSalesChartOptions.colors"
|
||||
[plotOptions]="productSalesChartOptions.plotOptions"
|
||||
[dataLabels]="productSalesChartOptions.dataLabels"
|
||||
[fill]="productSalesChartOptions.fill"
|
||||
[legend]="productSalesChartOptions.legend"
|
||||
[grid]="productSalesChartOptions.grid"
|
||||
></apx-chart>
|
||||
</div>
|
||||
<!-- Line Chart for Trends -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full">
|
||||
<div class="text-lg font-medium leading-6 tracking-tight mb-4">
|
||||
Transaction Trends
|
||||
|
||||
<!-- Top Selling Products -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Top Selling Products</h3>
|
||||
<button mat-stroked-button [matMenuTriggerFor]="topProductsMenu" class="text-sm border border-purple-300 text-purple-700 rounded-lg px-4 py-2 hover:bg-purple-50 flex items-center gap-2">
|
||||
<mat-icon class="text-lg">schedule</mat-icon>
|
||||
<span class="capitalize font-medium">{{ topProductsTimeRange }}</span>
|
||||
<mat-icon class="text-lg">expand_more</mat-icon>
|
||||
</button>
|
||||
<mat-menu #topProductsMenu="matMenu">
|
||||
<button mat-menu-item (click)="updateTopProductsTimeRange('day')">
|
||||
<mat-icon>today</mat-icon>
|
||||
<span>Daily</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateTopProductsTimeRange('week')">
|
||||
<mat-icon>date_range</mat-icon>
|
||||
<span>Weekly</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateTopProductsTimeRange('month')">
|
||||
<mat-icon>calendar_month</mat-icon>
|
||||
<span>Monthly</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateTopProductsTimeRange('year')">
|
||||
<mat-icon>calendar_today</mat-icon>
|
||||
<span>Yearly</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
<apx-chart
|
||||
[series]="lineChartOptions.series"
|
||||
[chart]="lineChartOptions.chart"
|
||||
[xaxis]="lineChartOptions.xaxis"
|
||||
[yaxis]="lineChartOptions.yaxis"
|
||||
[colors]="lineChartOptions.colors"
|
||||
[stroke]="lineChartOptions.stroke"
|
||||
[tooltip]="lineChartOptions.tooltip"
|
||||
></apx-chart>
|
||||
</div>
|
||||
<!-- Doughnut Chart for Sales Distribution -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full">
|
||||
<div class="text-lg font-medium leading-6 tracking-tight mb-4">
|
||||
Sales Distribution
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<table class="w-full">
|
||||
<thead class="bg-teal-600 text-white sticky top-0">
|
||||
<tr>
|
||||
<th class="text-left p-3 text-xs font-semibold">Product</th>
|
||||
<th class="text-right p-3 text-xs font-semibold">Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let product of topSellingProducts; let i = index"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
[ngClass]="{'bg-gray-50': i % 2 === 0}">
|
||||
<td class="p-3 text-sm text-gray-700">{{ product.product_name }}</td>
|
||||
<td class="p-3 text-sm text-right font-semibold text-gray-900">{{ product.quantity | number }}</td>
|
||||
</tr>
|
||||
<tr *ngIf="topSellingProducts.length === 0">
|
||||
<td colspan="2" class="p-6 text-center text-gray-500">
|
||||
<mat-icon class="text-4xl text-gray-300 mb-2">inbox</mat-icon>
|
||||
<div class="text-sm">No data available</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<apx-chart
|
||||
[series]="doughnutChartOptions.series"
|
||||
[chart]="doughnutChartOptions.chart"
|
||||
[labels]="doughnutChartOptions.labels"
|
||||
[colors]="doughnutChartOptions.colors"
|
||||
[responsive]="doughnutChartOptions.responsive"
|
||||
></apx-chart>
|
||||
</div>
|
||||
<!-- Pie Chart for Client User Categories -->
|
||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full">
|
||||
<div class="text-lg font-medium leading-6 tracking-tight mb-4">
|
||||
Client User Categories
|
||||
</div>
|
||||
<apx-chart
|
||||
[series]="pieChartOptions.series"
|
||||
[chart]="pieChartOptions.chart"
|
||||
[labels]="pieChartOptions.labels"
|
||||
[colors]="pieChartOptions.colors"
|
||||
[responsive]="pieChartOptions.responsive"
|
||||
></apx-chart>
|
||||
</div>
|
||||
|
||||
<!-- Sales Area Chart -->
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Sales Trend</h3>
|
||||
<button mat-stroked-button [matMenuTriggerFor]="salesMenu" class="text-sm border border-green-300 text-green-700 rounded-lg px-4 py-2 hover:bg-green-50 flex items-center gap-2">
|
||||
<mat-icon class="text-lg">schedule</mat-icon>
|
||||
<span class="capitalize font-medium">{{ salesTimeRange }}</span>
|
||||
<mat-icon class="text-lg">expand_more</mat-icon>
|
||||
</button>
|
||||
<mat-menu #salesMenu="matMenu">
|
||||
<button mat-menu-item (click)="updateSalesTimeRange('day')">
|
||||
<mat-icon>today</mat-icon>
|
||||
<span>Daily</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateSalesTimeRange('week')">
|
||||
<mat-icon>date_range</mat-icon>
|
||||
<span>Weekly</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateSalesTimeRange('month')">
|
||||
<mat-icon>calendar_month</mat-icon>
|
||||
<span>Monthly</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="updateSalesTimeRange('year')">
|
||||
<mat-icon>calendar_today</mat-icon>
|
||||
<span>Yearly</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
<apx-chart
|
||||
[series]="salesChartOptions.series"
|
||||
[chart]="salesChartOptions.chart"
|
||||
[xaxis]="salesChartOptions.xaxis"
|
||||
[yaxis]="salesChartOptions.yaxis"
|
||||
[colors]="salesChartOptions.colors"
|
||||
[stroke]="salesChartOptions.stroke"
|
||||
[dataLabels]="salesChartOptions.dataLabels"
|
||||
[fill]="salesChartOptions.fill"
|
||||
[grid]="salesChartOptions.grid"
|
||||
></apx-chart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -23,10 +23,23 @@ interface DashboardMetrics {
|
||||
client_users: number;
|
||||
transactions: number;
|
||||
sales: string;
|
||||
active_machines?: number;
|
||||
inactive_machines?: number;
|
||||
machine_operation_status?: { [key: string]: number };
|
||||
machine_stock_status?: { [key: string]: number };
|
||||
payment_breakdown?: {
|
||||
cash: number;
|
||||
cashless: number;
|
||||
upi_wallet_card: number;
|
||||
upi_wallet_paytm: number;
|
||||
refund: number;
|
||||
refund_processed: number;
|
||||
total: number;
|
||||
};
|
||||
product_sales_yearly?: { year: string; amount: number }[];
|
||||
sales_yearly?: { year: string; amount: number }[];
|
||||
top_selling_products?: { product_name: string; quantity: number }[];
|
||||
error?: string;
|
||||
transaction_trends?: { date: string; transactions: number }[];
|
||||
sales_distribution?: { [key: string]: number };
|
||||
client_user_categories?: { [key: string]: number };
|
||||
}
|
||||
|
||||
@Component({
|
||||
@ -56,112 +69,41 @@ export class ExampleComponent implements OnInit {
|
||||
clientUsers: number = 0;
|
||||
transactions: number = 0;
|
||||
sales: string = '₹0.00';
|
||||
activeMachines: number = 0;
|
||||
inactiveMachines: number = 0;
|
||||
errorMessage: string = '';
|
||||
loading: boolean = false;
|
||||
barChartOptions: Partial<ApexOptions>;
|
||||
lineChartOptions: Partial<ApexOptions>;
|
||||
doughnutChartOptions: Partial<ApexOptions>;
|
||||
pieChartOptions: Partial<ApexOptions>;
|
||||
|
||||
// Payment breakdown
|
||||
paymentCash: number = 0;
|
||||
paymentCashless: number = 0;
|
||||
paymentUPIWalletCard: number = 0;
|
||||
paymentUPIWalletPaytm: number = 0;
|
||||
refund: number = 0;
|
||||
refundProcessed: number = 0;
|
||||
paymentTotal: number = 0;
|
||||
|
||||
// Machine operation status
|
||||
machineOperationStatus: { [key: string]: number } = {};
|
||||
|
||||
// Machine stock status
|
||||
machineStockStatus: { [key: string]: number } = {};
|
||||
|
||||
// Top selling products
|
||||
topSellingProducts: { product_name: string; quantity: number }[] = [];
|
||||
|
||||
// Filter states
|
||||
productSalesTimeRange: string = 'year';
|
||||
salesTimeRange: string = 'year';
|
||||
topProductsTimeRange: string = 'year';
|
||||
|
||||
productSalesChartOptions: Partial<ApexOptions>;
|
||||
salesChartOptions: Partial<ApexOptions>;
|
||||
|
||||
private readonly baseUrl = environment.apiUrl || 'http://localhost:5000';
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
// Initialize bar chart options with mock data
|
||||
this.barChartOptions = {
|
||||
series: [{
|
||||
name: 'Metrics',
|
||||
data: [150, 80, 120, 60]
|
||||
}],
|
||||
chart: {
|
||||
type: 'bar',
|
||||
height: 350
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '55%'
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
xaxis: {
|
||||
categories: ['Machines', 'Clients', 'Company Users', 'Client Users']
|
||||
},
|
||||
colors: ['#3B82F6', '#EF4444', '#F59E0B', '#EF4444'],
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val) => `${val}`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize line chart options with mock data
|
||||
this.lineChartOptions = {
|
||||
series: [{
|
||||
name: 'Transactions',
|
||||
data: [10, 15, 8, 12, 20]
|
||||
}],
|
||||
chart: {
|
||||
type: 'line',
|
||||
height: 350
|
||||
},
|
||||
stroke: {
|
||||
width: 3,
|
||||
curve: 'smooth'
|
||||
},
|
||||
xaxis: {
|
||||
type: 'category',
|
||||
categories: ['2025-08-15', '2025-08-16', '2025-08-17', '2025-08-18', '2025-08-19']
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Transactions'
|
||||
}
|
||||
},
|
||||
colors: ['#EF4444'],
|
||||
tooltip: {
|
||||
x: {
|
||||
format: 'dd MMM yyyy'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize doughnut chart options with mock data
|
||||
this.doughnutChartOptions = {
|
||||
series: [40, 30, 20],
|
||||
chart: {
|
||||
type: 'donut',
|
||||
height: 350
|
||||
},
|
||||
labels: ['Product A', 'Product B', 'Product C'],
|
||||
colors: ['#10B981', '#F59E0B', '#8B5CF6'],
|
||||
responsive: [{
|
||||
breakpoint: 480,
|
||||
options: {
|
||||
chart: { width: 200 },
|
||||
legend: { position: 'bottom' }
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
// Initialize pie chart options with mock data
|
||||
this.pieChartOptions = {
|
||||
series: [50, 30, 20],
|
||||
chart: {
|
||||
type: 'pie',
|
||||
height: 350
|
||||
},
|
||||
labels: ['Premium', 'Standard', 'Basic'],
|
||||
colors: ['#EF4444', '#6B7280', '#34D399'],
|
||||
responsive: [{
|
||||
breakpoint: 480,
|
||||
options: {
|
||||
chart: { width: 200 },
|
||||
legend: { position: 'bottom' }
|
||||
}
|
||||
}]
|
||||
};
|
||||
this.initializeCharts();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -169,35 +111,259 @@ export class ExampleComponent implements OnInit {
|
||||
}
|
||||
|
||||
updateMachine(filter: 'active' | 'inactive' | 'all') {
|
||||
console.log('Filter clicked:', filter);
|
||||
this.fetchDashboardMetrics(filter);
|
||||
}
|
||||
|
||||
updateProductSalesTimeRange(range: string) {
|
||||
this.productSalesTimeRange = range;
|
||||
console.log('Product Sales Time Range:', range);
|
||||
this.fetchProductSalesData(range);
|
||||
}
|
||||
|
||||
updateSalesTimeRange(range: string) {
|
||||
this.salesTimeRange = range;
|
||||
console.log('Sales Time Range:', range);
|
||||
this.fetchSalesData(range);
|
||||
}
|
||||
|
||||
updateTopProductsTimeRange(range: string) {
|
||||
this.topProductsTimeRange = range;
|
||||
console.log('Top Products Time Range:', range);
|
||||
this.fetchTopProductsData(range);
|
||||
}
|
||||
|
||||
private fetchProductSalesData(timeRange: string) {
|
||||
const url = `${this.baseUrl}/product-sales?time_range=${timeRange}`;
|
||||
console.log('Fetching product sales data:', timeRange);
|
||||
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('access_token');
|
||||
|
||||
this.http.get<{ product_sales: { year: string; amount: number }[] }>(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
}
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
console.log('✓ Product Sales Data:', response);
|
||||
if (response.product_sales && response.product_sales.length > 0) {
|
||||
this.productSalesChartOptions = {
|
||||
...this.productSalesChartOptions,
|
||||
series: [{
|
||||
name: 'Sales',
|
||||
data: response.product_sales.map(item => item.amount)
|
||||
}],
|
||||
xaxis: {
|
||||
...this.productSalesChartOptions.xaxis,
|
||||
categories: response.product_sales.map(item => item.year)
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('✗ Error fetching product sales data:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fetchSalesData(timeRange: string) {
|
||||
const url = `${this.baseUrl}/sales-data?time_range=${timeRange}`;
|
||||
console.log('Fetching sales data:', timeRange);
|
||||
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('access_token');
|
||||
|
||||
this.http.get<{ sales_data: { year: string; amount: number }[] }>(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
}
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
console.log('✓ Sales Data:', response);
|
||||
if (response.sales_data && response.sales_data.length > 0) {
|
||||
this.salesChartOptions = {
|
||||
...this.salesChartOptions,
|
||||
series: [{
|
||||
name: 'Sales',
|
||||
data: response.sales_data.map(item => item.amount)
|
||||
}],
|
||||
xaxis: {
|
||||
...this.salesChartOptions.xaxis,
|
||||
categories: response.sales_data.map(item => item.year)
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('✗ Error fetching sales data:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fetchTopProductsData(timeRange: string) {
|
||||
const url = `${this.baseUrl}/top-products?time_range=${timeRange}`;
|
||||
console.log('Fetching top products data:', timeRange);
|
||||
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('access_token');
|
||||
|
||||
this.http.get<{ top_products: { product_name: string; quantity: number }[] }>(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
}
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
console.log('✓ Top Products Data:', response);
|
||||
if (response.top_products) {
|
||||
this.topSellingProducts = response.top_products;
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('✗ Error fetching top products data:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initializeCharts() {
|
||||
// Product Sales Chart
|
||||
this.productSalesChartOptions = {
|
||||
series: [{
|
||||
name: 'Sales',
|
||||
data: []
|
||||
}],
|
||||
chart: {
|
||||
type: 'bar',
|
||||
height: 350,
|
||||
stacked: true,
|
||||
toolbar: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '80%',
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
xaxis: {
|
||||
categories: [],
|
||||
labels: {
|
||||
style: {
|
||||
fontSize: '12px'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Rupees'
|
||||
},
|
||||
labels: {
|
||||
formatter: (val) => `₹${(val / 1000000).toFixed(1)}M`
|
||||
}
|
||||
},
|
||||
colors: ['#3B82F6', '#EF4444', '#F59E0B', '#10B981', '#8B5CF6'],
|
||||
fill: {
|
||||
opacity: 1
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
grid: {
|
||||
borderColor: '#f1f1f1'
|
||||
}
|
||||
};
|
||||
|
||||
// Sales Chart (Area)
|
||||
this.salesChartOptions = {
|
||||
series: [{
|
||||
name: 'Sales',
|
||||
data: []
|
||||
}],
|
||||
chart: {
|
||||
type: 'area',
|
||||
height: 350,
|
||||
toolbar: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2
|
||||
},
|
||||
xaxis: {
|
||||
categories: [],
|
||||
labels: {
|
||||
style: {
|
||||
fontSize: '12px'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Rupees'
|
||||
},
|
||||
labels: {
|
||||
formatter: (val) => `₹${(val / 1000000).toFixed(1)}M`
|
||||
}
|
||||
},
|
||||
colors: ['#14B8A6'],
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.7,
|
||||
opacityTo: 0.3,
|
||||
stops: [0, 90, 100]
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
borderColor: '#f1f1f1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private fetchDashboardMetrics(filter: string) {
|
||||
this.loading = true;
|
||||
this.machineTitle = 'Loading...';
|
||||
this.errorMessage = '';
|
||||
|
||||
const url = `${this.baseUrl}/dashboard-metrics?machine_filter=${filter}`;
|
||||
console.log('Fetching from URL:', url);
|
||||
console.log('Fetching dashboard data with filter:', filter);
|
||||
console.log('URL:', url);
|
||||
|
||||
// Get token from localStorage
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('access_token');
|
||||
|
||||
this.http.get<DashboardMetrics>(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
}
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
console.log('API Response:', response);
|
||||
console.log('✓ Dashboard API Response:', response);
|
||||
this.loading = false;
|
||||
|
||||
if (response.error) {
|
||||
this.handleError(`Server Error: ${response.error}`);
|
||||
} else {
|
||||
this.updateDashboardData(response);
|
||||
console.log('✓ Dashboard data updated successfully');
|
||||
}
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
console.error('HTTP Error:', error);
|
||||
console.error('✗ Dashboard HTTP Error:', error);
|
||||
this.loading = false;
|
||||
this.handleHttpError(error);
|
||||
}
|
||||
@ -205,6 +371,7 @@ export class ExampleComponent implements OnInit {
|
||||
}
|
||||
|
||||
private updateDashboardData(response: DashboardMetrics) {
|
||||
// Update basic metrics
|
||||
this.machineTitle = response.machine_title || 'Machines';
|
||||
this.machineCount = response.machine_count || 0;
|
||||
this.clients = response.clients || 0;
|
||||
@ -212,54 +379,73 @@ export class ExampleComponent implements OnInit {
|
||||
this.clientUsers = response.client_users || 0;
|
||||
this.transactions = response.transactions || 0;
|
||||
this.sales = `₹${response.sales || '0.00'}`;
|
||||
this.activeMachines = response.active_machines || 0;
|
||||
this.inactiveMachines = response.inactive_machines || 0;
|
||||
this.errorMessage = '';
|
||||
|
||||
// Update bar chart
|
||||
this.barChartOptions = {
|
||||
...this.barChartOptions,
|
||||
series: [{
|
||||
name: 'Metrics',
|
||||
data: [
|
||||
this.machineCount,
|
||||
this.clients,
|
||||
this.companyUsers,
|
||||
this.clientUsers
|
||||
]
|
||||
}]
|
||||
};
|
||||
// Update payment breakdown
|
||||
if (response.payment_breakdown) {
|
||||
this.paymentCash = response.payment_breakdown.cash || 0;
|
||||
this.paymentCashless = response.payment_breakdown.cashless || 0;
|
||||
this.paymentUPIWalletCard = response.payment_breakdown.upi_wallet_card || 0;
|
||||
this.paymentUPIWalletPaytm = response.payment_breakdown.upi_wallet_paytm || 0;
|
||||
this.refund = response.payment_breakdown.refund || 0;
|
||||
this.refundProcessed = response.payment_breakdown.refund_processed || 0;
|
||||
this.paymentTotal = response.payment_breakdown.total || 0;
|
||||
}
|
||||
|
||||
// Update line chart with transaction trends
|
||||
if (response.transaction_trends) {
|
||||
this.lineChartOptions = {
|
||||
...this.lineChartOptions,
|
||||
// Update machine operation status
|
||||
if (response.machine_operation_status) {
|
||||
this.machineOperationStatus = response.machine_operation_status;
|
||||
}
|
||||
|
||||
// Update machine stock status
|
||||
if (response.machine_stock_status) {
|
||||
this.machineStockStatus = response.machine_stock_status;
|
||||
}
|
||||
|
||||
// Update top selling products
|
||||
if (response.top_selling_products) {
|
||||
this.topSellingProducts = response.top_selling_products;
|
||||
}
|
||||
|
||||
// Update Product Sales Chart
|
||||
if (response.product_sales_yearly && response.product_sales_yearly.length > 0) {
|
||||
this.productSalesChartOptions = {
|
||||
...this.productSalesChartOptions,
|
||||
series: [{
|
||||
name: 'Transactions',
|
||||
data: response.transaction_trends.map(item => item.transactions)
|
||||
name: 'Sales',
|
||||
data: response.product_sales_yearly.map(item => item.amount)
|
||||
}],
|
||||
xaxis: {
|
||||
...this.lineChartOptions.xaxis,
|
||||
categories: response.transaction_trends.map(item => item.date)
|
||||
...this.productSalesChartOptions.xaxis,
|
||||
categories: response.product_sales_yearly.map(item => item.year)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Update doughnut chart with sales distribution
|
||||
if (response.sales_distribution) {
|
||||
this.doughnutChartOptions = {
|
||||
...this.doughnutChartOptions,
|
||||
series: Object.values(response.sales_distribution),
|
||||
labels: Object.keys(response.sales_distribution)
|
||||
// Update Sales Chart
|
||||
if (response.sales_yearly && response.sales_yearly.length > 0) {
|
||||
this.salesChartOptions = {
|
||||
...this.salesChartOptions,
|
||||
series: [{
|
||||
name: 'Sales',
|
||||
data: response.sales_yearly.map(item => item.amount)
|
||||
}],
|
||||
xaxis: {
|
||||
...this.salesChartOptions.xaxis,
|
||||
categories: response.sales_yearly.map(item => item.year)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update pie chart with client user categories
|
||||
if (response.client_user_categories) {
|
||||
this.pieChartOptions = {
|
||||
...this.pieChartOptions,
|
||||
series: Object.values(response.client_user_categories),
|
||||
labels: Object.keys(response.client_user_categories)
|
||||
};
|
||||
}
|
||||
getStockStatusKeys(): string[] {
|
||||
return Object.keys(this.machineStockStatus).sort();
|
||||
}
|
||||
|
||||
getOperationStatusKeys(): string[] {
|
||||
return Object.keys(this.machineOperationStatus).sort();
|
||||
}
|
||||
|
||||
private handleError(message: string) {
|
||||
@ -272,18 +458,15 @@ export class ExampleComponent implements OnInit {
|
||||
|
||||
if (error.status === 0) {
|
||||
errorMessage += 'Cannot connect to server. Please check if the Flask server is running on the correct port.';
|
||||
} else if (error.status === 401) {
|
||||
errorMessage += 'Authentication required. Please login again.';
|
||||
} else if (error.status === 404) {
|
||||
errorMessage += 'API endpoint not found. Please check the server routing.';
|
||||
} else if (error.status >= 500) {
|
||||
errorMessage += 'Internal server error. Please check the server logs.';
|
||||
} else {
|
||||
errorMessage += `Status: ${error.status} - ${error.statusText}. `;
|
||||
|
||||
if (error.error && typeof error.error === 'string' && error.error.includes('<!doctype')) {
|
||||
errorMessage += 'Server returned HTML instead of JSON. Check your proxy configuration.';
|
||||
} else {
|
||||
errorMessage += `Details: ${error.error?.error || error.message || 'Unknown error'}`;
|
||||
}
|
||||
errorMessage += `Details: ${error.error?.error || error.message || 'Unknown error'}`;
|
||||
}
|
||||
|
||||
this.handleError(errorMessage);
|
||||
@ -297,39 +480,18 @@ export class ExampleComponent implements OnInit {
|
||||
this.clientUsers = 0;
|
||||
this.transactions = 0;
|
||||
this.sales = '₹0.00';
|
||||
|
||||
// Reset chart data
|
||||
this.barChartOptions = {
|
||||
...this.barChartOptions,
|
||||
series: [{
|
||||
name: 'Metrics',
|
||||
data: [0, 0, 0, 0]
|
||||
}]
|
||||
};
|
||||
|
||||
this.lineChartOptions = {
|
||||
...this.lineChartOptions,
|
||||
series: [{
|
||||
name: 'Transactions',
|
||||
data: []
|
||||
}],
|
||||
xaxis: {
|
||||
...this.lineChartOptions.xaxis,
|
||||
categories: []
|
||||
}
|
||||
};
|
||||
|
||||
this.doughnutChartOptions = {
|
||||
...this.doughnutChartOptions,
|
||||
series: [],
|
||||
labels: []
|
||||
};
|
||||
|
||||
this.pieChartOptions = {
|
||||
...this.pieChartOptions,
|
||||
series: [],
|
||||
labels: []
|
||||
};
|
||||
this.activeMachines = 0;
|
||||
this.inactiveMachines = 0;
|
||||
this.paymentCash = 0;
|
||||
this.paymentCashless = 0;
|
||||
this.paymentUPIWalletCard = 0;
|
||||
this.paymentUPIWalletPaytm = 0;
|
||||
this.refund = 0;
|
||||
this.refundProcessed = 0;
|
||||
this.paymentTotal = 0;
|
||||
this.machineOperationStatus = {};
|
||||
this.machineStockStatus = {};
|
||||
this.topSellingProducts = [];
|
||||
}
|
||||
|
||||
retryFetch() {
|
||||
|
||||
@ -22,22 +22,21 @@
|
||||
<link rel="icon" type="image/png" href="favicon-16x16.png" />
|
||||
<link rel="icon" type="image/png" href="favicon-32x32.png" />
|
||||
|
||||
<!-- Remove these lines if files don't exist:
|
||||
<link href="fonts/inter/inter.css" rel="stylesheet" />
|
||||
<link href="styles/splash-screen.css" rel="stylesheet" />
|
||||
-->
|
||||
<!-- Fonts and Styles -->
|
||||
<link href="../public/fonts/inter/inter.css" rel="stylesheet" />
|
||||
<link href="../public/styles/splash-screen.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="mat-typography">
|
||||
<!-- Splash screen -->
|
||||
<fuse-splash-screen>
|
||||
<!-- <fuse-splash-screen>
|
||||
<img src="images/logo/logo.svg" alt="Fuse logo" />
|
||||
<div class="spinner">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
<div class="bounce3"></div>
|
||||
</div>
|
||||
</fuse-splash-screen>
|
||||
</fuse-splash-screen> -->
|
||||
|
||||
<!-- App root -->
|
||||
<app-root></app-root>
|
||||
|
||||
@ -103,5 +103,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": "15dead7e-04a1-4f40-8a32-c1cb1a5d23a0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// src/environments/environment.ts
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:5001', // Points to existing app's backend
|
||||
apiUrl: 'https://microbackend.rootxwire.com/', // Points to existing app's backend
|
||||
payuUrl: 'https://test.payu.in/_payment'
|
||||
};
|
||||