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_PASSWORD=vendingpass
|
||||||
MYSQL_DATABASE=vending
|
MYSQL_DATABASE=vending
|
||||||
|
|
||||||
SQLITE_DB_PATH=machines
|
SQLITE_DB_PATH=machines.db
|
||||||
|
|
||||||
BREVO_SMTP_EMAIL=smukeshsn2000@gmail.com
|
BREVO_SMTP_EMAIL=smukeshsn2000@gmail.com
|
||||||
BREVO_SMTP_KEY=your-brevo-smtp-key
|
BREVO_SMTP_KEY=your-brevo-smtp-key
|
||||||
|
|||||||
@ -53,6 +53,7 @@ def create_app():
|
|||||||
|
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
app.config['SQLALCHEMY_ECHO'] = os.getenv('SQLALCHEMY_ECHO', 'False').lower() == 'true'
|
app.config['SQLALCHEMY_ECHO'] = os.getenv('SQLALCHEMY_ECHO', 'False').lower() == 'true'
|
||||||
|
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
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
|
import json
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
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):
|
class Machine(db.Model):
|
||||||
__tablename__ = 'machines'
|
__tablename__ = 'machines'
|
||||||
|
|
||||||
@ -20,10 +48,37 @@ class Machine(db.Model):
|
|||||||
connection_status = db.Column(db.String(50), nullable=False)
|
connection_status = db.Column(db.String(50), nullable=False)
|
||||||
created_on = db.Column(db.String(20), nullable=False)
|
created_on = db.Column(db.String(20), nullable=False)
|
||||||
password = db.Column(db.String(128), 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)
|
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):
|
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 {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'machine_id': self.machine_id,
|
'machine_id': self.machine_id,
|
||||||
@ -36,7 +91,10 @@ class Machine(db.Model):
|
|||||||
'operation_status': self.operation_status,
|
'operation_status': self.operation_status,
|
||||||
'connection_status': self.connection_status,
|
'connection_status': self.connection_status,
|
||||||
'created_on': self.created_on,
|
'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):
|
def set_password(self, password):
|
||||||
@ -46,7 +104,7 @@ class Machine(db.Model):
|
|||||||
return check_password_hash(self.password, password)
|
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):
|
class User(db.Model):
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
|
|
||||||
@ -57,7 +115,9 @@ class User(db.Model):
|
|||||||
contact = db.Column(db.String(20), nullable=False)
|
contact = db.Column(db.String(20), nullable=False)
|
||||||
roles = db.Column(db.String(50), nullable=False)
|
roles = db.Column(db.String(50), nullable=False)
|
||||||
user_status = 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
|
# File storage fields
|
||||||
photo = db.Column(db.String(255), nullable=True)
|
photo = db.Column(db.String(255), nullable=True)
|
||||||
@ -67,9 +127,27 @@ class User(db.Model):
|
|||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=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):
|
def to_dict(self):
|
||||||
"""Convert user object to dictionary"""
|
"""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 {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'user_id': self.user_id,
|
'user_id': self.user_id,
|
||||||
@ -83,7 +161,12 @@ class User(db.Model):
|
|||||||
'documents': json.loads(self.documents) if self.documents else [],
|
'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,
|
'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,
|
'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):
|
def set_password(self, password):
|
||||||
@ -125,6 +208,13 @@ class Product(db.Model):
|
|||||||
price = db.Column(db.Float, nullable=False)
|
price = db.Column(db.Float, nullable=False)
|
||||||
product_image = db.Column(db.String(255), nullable=False)
|
product_image = db.Column(db.String(255), nullable=False)
|
||||||
created_date = db.Column(db.String(20), 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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@ -133,7 +223,11 @@ class Product(db.Model):
|
|||||||
'product_name': self.product_name,
|
'product_name': self.product_name,
|
||||||
'price': str(self.price),
|
'price': str(self.price),
|
||||||
'product_image': self.product_image,
|
'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,
|
'return_amount': self.return_amount,
|
||||||
'created_at': self.created_at.strftime("%Y-%m-%d %H:%M:%S") if self.created_at else None
|
'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):
|
class Role(db.Model):
|
||||||
__tablename__ = 'roles'
|
__tablename__ = 'roles'
|
||||||
|
|
||||||
@ -199,15 +295,260 @@ class Role(db.Model):
|
|||||||
name = db.Column(db.String(50), unique=True, nullable=False)
|
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
description = db.Column(db.String(255))
|
description = db.Column(db.String(255))
|
||||||
permissions = db.Column(db.Text) # JSON string of permission IDs
|
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)
|
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'permissions': json.loads(self.permissions) if self.permissions else [],
|
'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,
|
'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
|
'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
|
Database Migration Script for Categories Table
|
||||||
import os
|
"""
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.exc import OperationalError
|
|
||||||
|
|
||||||
def wait_for_db(max_retries=30, retry_delay=2):
|
from app import db, create_app
|
||||||
"""Wait for database to be ready"""
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def create_categories_table():
|
||||||
|
"""Create categories table"""
|
||||||
|
|
||||||
mysql_host = os.getenv('MYSQL_HOST', 'db')
|
create_table_sql = """
|
||||||
mysql_user = os.getenv('MYSQL_USER', 'vendinguser')
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
mysql_password = os.getenv('MYSQL_PASSWORD', 'vendingpass')
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
mysql_db = os.getenv('MYSQL_DATABASE', 'vending')
|
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):
|
brands = Brand.query.all()
|
||||||
try:
|
branches = Branch.query.all()
|
||||||
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)
|
|
||||||
|
|
||||||
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__':
|
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
|
# 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
|
# Add OPTIONS handler for preflight requests
|
||||||
@app.before_request
|
@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...' &&
|
echo '🚀 Starting Gunicorn server...' &&
|
||||||
gunicorn --bind 0.0.0.0:5000 --workers 2 --timeout 120 run:app
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@ -60,6 +89,7 @@ services:
|
|||||||
- vending-network
|
- vending-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
- microservice
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
/* 0. Themes */
|
||||||
|
@use 'themes';
|
||||||
|
|
||||||
/* 1. Components */
|
/* 1. Components */
|
||||||
@use 'components/example-viewer';
|
@use 'components/example-viewer';
|
||||||
@use 'components/input';
|
@use 'components/input';
|
||||||
|
|||||||
@ -153,7 +153,7 @@ export const appRoutes: Route[] = [
|
|||||||
{
|
{
|
||||||
path: 'role-management',
|
path: 'role-management',
|
||||||
canActivate: [RoleGuard],
|
canActivate: [RoleGuard],
|
||||||
data: { roles: ['Management', 'SuperAdmin'] },
|
data: { roles: ['Management', 'SuperAdmin', 'Admin'] },
|
||||||
loadChildren: () =>import('app/modules/admin/dashboard/role-management/role-management.routes')
|
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',
|
icon: 'heroicons_outline:user-group',
|
||||||
link: '/role-management',
|
link: '/role-management',
|
||||||
},
|
},
|
||||||
{
|
// Warehouse - Hidden
|
||||||
id: 'dashboards.warehouse',
|
// W. Transactions - Hidden
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'dashboards.advertisements',
|
id: 'dashboards.advertisements',
|
||||||
title: 'Advertisements',
|
title: 'Advertisements',
|
||||||
@ -249,33 +161,7 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
// User Management - Hidden
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'dashboards.api-integration',
|
id: 'dashboards.api-integration',
|
||||||
title: 'API Integration',
|
title: 'API Integration',
|
||||||
@ -308,18 +194,18 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
|||||||
type: 'basic',
|
type: 'basic',
|
||||||
link: '/company/company-admin-list',
|
link: '/company/company-admin-list',
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
id: 'dashboards.company.company-user-list',
|
// id: 'dashboards.company.company-user-list',
|
||||||
title: 'Company User List',
|
// title: 'Company User List',
|
||||||
type: 'basic',
|
// type: 'basic',
|
||||||
link: '/company/company-user-list',
|
// link: '/company/company-user-list',
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 'dashboards.company.company-user-role-list',
|
// id: 'dashboards.company.company-user-role-list',
|
||||||
title: 'Company User Role List',
|
// title: 'Company User Role List',
|
||||||
type: 'basic',
|
// type: 'basic',
|
||||||
link: 'company/company-user-role-list',
|
// link: 'company/company-user-role-list',
|
||||||
},
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -349,27 +235,7 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
// Offers & Coupons - Hidden
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'dashboards.help-support',
|
id: 'dashboards.help-support',
|
||||||
title: 'Help & Support',
|
title: 'Help & Support',
|
||||||
@ -377,14 +243,7 @@ export const defaultNavigation: FuseNavigationItem[] = [
|
|||||||
icon: 'heroicons_outline:information-circle',
|
icon: 'heroicons_outline:information-circle',
|
||||||
link: '/help-support',
|
link: '/help-support',
|
||||||
},
|
},
|
||||||
{
|
// Support History - Hidden
|
||||||
id: 'dashboards.support-history',
|
|
||||||
title: 'Support History',
|
|
||||||
type: 'basic',
|
|
||||||
icon: 'heroicons_outline:clock',
|
|
||||||
link: '/support-history',
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -395,7 +254,7 @@ export const compactNavigation: FuseNavigationItem[] = [
|
|||||||
tooltip: 'Dashboards',
|
tooltip: 'Dashboards',
|
||||||
type: 'aside',
|
type: 'aside',
|
||||||
icon: 'heroicons_outline:home',
|
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[] = [
|
export const futuristicNavigation: FuseNavigationItem[] = [
|
||||||
@ -403,7 +262,7 @@ export const futuristicNavigation: FuseNavigationItem[] = [
|
|||||||
id: 'dashboards',
|
id: 'dashboards',
|
||||||
title: 'DASHBOARDS',
|
title: 'DASHBOARDS',
|
||||||
type: 'group',
|
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[] = [
|
export const horizontalNavigation: FuseNavigationItem[] = [
|
||||||
@ -412,6 +271,6 @@ export const horizontalNavigation: FuseNavigationItem[] = [
|
|||||||
title: 'Dashboards',
|
title: 'Dashboards',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
icon: 'heroicons_outline:home',
|
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({
|
@Component({
|
||||||
selector: 'app-branch-list',
|
selector: 'app-branch-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
],
|
||||||
templateUrl: './branch-list.component.html',
|
templateUrl: './branch-list.component.html',
|
||||||
styleUrl: './branch-list.component.scss'
|
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({
|
@Component({
|
||||||
selector: 'app-client-list',
|
selector: 'app-client-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatExpansionModule
|
||||||
|
],
|
||||||
templateUrl: './client-list.component.html',
|
templateUrl: './client-list.component.html',
|
||||||
styleUrl: './client-list.component.scss'
|
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({
|
@Component({
|
||||||
selector: 'app-company-admin-list',
|
selector: 'app-company-admin-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormsModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule
|
||||||
|
],
|
||||||
templateUrl: './company-admin-list.component.html',
|
templateUrl: './company-admin-list.component.html',
|
||||||
styleUrl: './company-admin-list.component.scss'
|
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="flex-auto p-6 sm:p-10 overflow-auto max-h-[calc(100vh-100px)]">
|
||||||
<div class="machine-details-container">
|
<div class="machine-details-container">
|
||||||
|
<!-- Header Bar -->
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
<button class="back-button" (click)="goBack()">
|
<button class="back-button" (click)="goBack()">
|
||||||
<i class="fa fa-arrow-left"></i> Back
|
<i class="fa fa-arrow-left"></i> Back
|
||||||
</button>
|
</button>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="save-button" (click)="saveChanges()">
|
<button class="save-button" (click)="saveChanges()" [disabled]="isLoading">
|
||||||
<i class="fa fa-save"></i> Save Changes
|
<i class="fa fa-save"></i>
|
||||||
|
{{ isLoading ? 'Saving...' : 'Save Changes' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -17,8 +20,13 @@
|
|||||||
<button class="retry-button" (click)="loadMachineData()">Retry</button>
|
<button class="retry-button" (click)="loadMachineData()">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Spinner -->
|
||||||
|
<div *ngIf="isLoading" class="loading-spinner">
|
||||||
|
<i class="fa fa-spinner fa-spin"></i> Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Information section -->
|
<!-- Information section -->
|
||||||
<div class="info-card" *ngIf="!errorMessage">
|
<div class="info-card" *ngIf="!errorMessage && !isLoading">
|
||||||
<h3>Information</h3>
|
<h3>Information</h3>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
@ -61,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Vending rows container -->
|
<!-- 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 *ngFor="let row of vendingRows" class="vending-row">
|
||||||
<div class="row-title">
|
<div class="row-title">
|
||||||
<span>{{row.name}}</span>
|
<span>{{row.name}}</span>
|
||||||
@ -73,19 +81,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="slots-container">
|
<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">
|
<div class="slot-header">
|
||||||
<span class="slot-name">{{slot.name}}</span>
|
<span class="slot-name">{{slot.name}}</span>
|
||||||
<div class="toggle-container">
|
<div class="toggle-container">
|
||||||
<label class="switch">
|
<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>
|
<span class="slider"></span>
|
||||||
</label>
|
</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'}}
|
{{slot.enabled ? 'Enabled' : 'Disabled'}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot Body -->
|
||||||
<div class="slot-body" (click)="openProductSelector(row, slot, $event)">
|
<div class="slot-body" (click)="openProductSelector(row, slot, $event)">
|
||||||
<ng-container *ngIf="!slot.product; else productAssigned">
|
<ng-container *ngIf="!slot.product; else productAssigned">
|
||||||
<div class="empty-slot">
|
<div class="empty-slot">
|
||||||
@ -95,16 +112,27 @@
|
|||||||
<p class="add-product-text">Assign Product</p>
|
<p class="add-product-text">Assign Product</p>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #productAssigned>
|
<ng-template #productAssigned>
|
||||||
<div class="product-image-container">
|
<div class="product-image-container">
|
||||||
<img *ngIf="slot.product?.product_image"
|
<!-- Show image if valid -->
|
||||||
[src]="'https://iotbackend.rootxwire.com/' + slot.product?.product_image"
|
<img *ngIf="hasValidImage(slot.product?.product_image)"
|
||||||
[attr.alt]="slot.product?.product_name" class="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>
|
||||||
<div class="product-details">
|
<div class="product-details">
|
||||||
<div class="product-header">
|
<div class="product-header">
|
||||||
<p class="product-name">{{slot.product.product_name}}</p>
|
<p class="product-name">{{slot.product?.product_name}}</p>
|
||||||
<button class="remove-product" (click)="removeProductFromSlot(slot, $event)">
|
<button class="remove-product"
|
||||||
|
(click)="removeProductFromSlot(slot, $event)">
|
||||||
<i class="fa fa-times"></i>
|
<i class="fa fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -122,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product Selector Modal -->
|
<!-- 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="product-selector-modal" (click)="$event.stopPropagation()">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Select Product for {{selectedSlot?.name}}</h3>
|
<h3>Select Product for {{selectedSlot?.name}}</h3>
|
||||||
@ -132,56 +160,92 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<!-- Search Bar -->
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" placeholder="Search products..." class="search-input" [(ngModel)]="searchTerm"
|
<input type="text"
|
||||||
(input)="filterProducts()">
|
placeholder="Search products..."
|
||||||
|
class="search-input"
|
||||||
|
[(ngModel)]="searchTerm"
|
||||||
|
(input)="filterProducts()">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Products Table -->
|
||||||
<div class="products-table">
|
<div class="products-table">
|
||||||
<div *ngFor="let product of filteredProducts; let i = index" class="product-row"
|
<div *ngFor="let product of filteredProducts; let i = index"
|
||||||
[ngClass]="{'selected': product.selected, 'odd': i % 2 === 0}"
|
class="product-row"
|
||||||
(click)="onProductSelectionChange(product)">
|
[ngClass]="{'selected': product.selected, 'odd': i % 2 === 0}"
|
||||||
|
(click)="onProductSelectionChange(product)">
|
||||||
|
|
||||||
|
<!-- Checkbox -->
|
||||||
<div class="checkbox-cell">
|
<div class="checkbox-cell">
|
||||||
<input type="checkbox" [checked]="product.selected" [id]="'product-' + product.product_id"
|
<input type="checkbox"
|
||||||
(click)="$event.stopPropagation(); onProductSelectionChange(product);">
|
[checked]="product.selected"
|
||||||
|
[id]="'product-' + product.product_id"
|
||||||
|
(click)="$event.stopPropagation(); onProductSelectionChange(product)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Product ID -->
|
||||||
<div class="number-cell">{{product.product_id}}</div>
|
<div class="number-cell">{{product.product_id}}</div>
|
||||||
|
|
||||||
|
<!-- Product Name -->
|
||||||
<div class="product-name-cell">{{product.product_name}}</div>
|
<div class="product-name-cell">{{product.product_name}}</div>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
<div class="price-cell">₹{{product.price}}</div>
|
<div class="price-cell">₹{{product.price}}</div>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
<div class="image-cell">
|
<div class="image-cell">
|
||||||
<img *ngIf="product.product_image" [src]="'http://localhost:5000/' + product.product_image"
|
<!-- Show image if valid -->
|
||||||
alt="{{product.product_name}}" class="product-thumbnail">
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quantity Spinner (only for selected product) -->
|
||||||
<div class="quantity-cell">
|
<div class="quantity-cell">
|
||||||
<div class="quantity-spinner" *ngIf="product.selected">
|
<div class="quantity-spinner" *ngIf="product.selected">
|
||||||
<button class="quantity-btn" [disabled]="productQuantity <= 1"
|
<button class="quantity-btn"
|
||||||
(click)="$event.stopPropagation(); decreaseQuantity()">
|
[disabled]="productQuantity <= 1"
|
||||||
|
(click)="$event.stopPropagation(); decreaseQuantity()">
|
||||||
<i class="fa fa-minus"></i>
|
<i class="fa fa-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
<input type="number" class="quantity-input" [(ngModel)]="productQuantity" min="1"
|
<input type="number"
|
||||||
(click)="$event.stopPropagation()" (change)="setQuantity($event)">
|
class="quantity-input"
|
||||||
<button class="quantity-btn" (click)="$event.stopPropagation(); increaseQuantity()">
|
[(ngModel)]="productQuantity"
|
||||||
|
min="1"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(change)="setQuantity($event)">
|
||||||
|
<button class="quantity-btn"
|
||||||
|
(click)="$event.stopPropagation(); increaseQuantity()">
|
||||||
<i class="fa fa-plus"></i>
|
<i class="fa fa-plus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- No Products Found -->
|
||||||
<div *ngIf="filteredProducts.length === 0" class="no-products">
|
<div *ngIf="filteredProducts.length === 0" class="no-products">
|
||||||
No products found matching "{{searchTerm}}"
|
No products found matching "{{searchTerm}}"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-cancel" (click)="closeProductSelector()">Cancel</button>
|
<button class="btn-cancel" (click)="closeProductSelector()">
|
||||||
<button class="btn-add" [disabled]="!selectedProduct" (click)="confirmProductSelection()">
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-add"
|
||||||
|
[disabled]="!selectedProduct"
|
||||||
|
(click)="confirmProductSelection()">
|
||||||
Add Product
|
Add Product
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 & row
|
||||||
.vending-container {
|
.vending-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,10 +1,26 @@
|
|||||||
|
// src/app/machines/machine-details/machine-details.component.ts
|
||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
import { MachineService, Machine } from '../machine.service';
|
import { MachineService, Machine } from '../machine.service';
|
||||||
import { ProductService } from '../../products/product.service';
|
import { ProductService, Product } from '../../products/product.service';
|
||||||
import { Subscription } from 'rxjs';
|
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({
|
@Component({
|
||||||
selector: 'app-machine-details',
|
selector: 'app-machine-details',
|
||||||
@ -26,7 +42,7 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
connection_status: ''
|
connection_status: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
vendingRows = ['A', 'B', 'C', 'D', 'E', 'F'].map(rowId => ({
|
vendingRows: VendingRow[] = ['A', 'B', 'C', 'D', 'E', 'F'].map(rowId => ({
|
||||||
name: `Row ${rowId}`,
|
name: `Row ${rowId}`,
|
||||||
id: rowId,
|
id: rowId,
|
||||||
slots: Array.from({ length: 10 }, (_, i) => ({
|
slots: Array.from({ length: 10 }, (_, i) => ({
|
||||||
@ -38,16 +54,21 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
products = [];
|
products: Product[] = [];
|
||||||
filteredProducts = [];
|
filteredProducts: Product[] = [];
|
||||||
searchTerm = '';
|
searchTerm = '';
|
||||||
showProductSelector = false;
|
showProductSelector = false;
|
||||||
selectedSlot = null;
|
selectedSlot: Slot | null = null;
|
||||||
selectedRow = null;
|
selectedRow: VendingRow | null = null;
|
||||||
selectedProduct = null;
|
selectedProduct: Product | null = null;
|
||||||
productQuantity = 1;
|
productQuantity = 1;
|
||||||
|
|
||||||
private subscriptions: Subscription[] = [];
|
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(
|
constructor(
|
||||||
private machineService: MachineService,
|
private machineService: MachineService,
|
||||||
@ -65,67 +86,78 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load machine data from route or service
|
||||||
|
*/
|
||||||
loadMachineData(): void {
|
loadMachineData(): void {
|
||||||
// First, try to get the machine ID from the route
|
|
||||||
const machineId = this.route.snapshot.paramMap.get('id');
|
const machineId = this.route.snapshot.paramMap.get('id');
|
||||||
|
|
||||||
if (machineId) {
|
if (machineId) {
|
||||||
this.machineService.getMachineById(Number(machineId)).subscribe(
|
this.isLoading = true;
|
||||||
fetchedMachine => {
|
this.machineService.getMachineById(Number(machineId)).subscribe({
|
||||||
|
next: (fetchedMachine) => {
|
||||||
if (fetchedMachine?.machine_id) {
|
if (fetchedMachine?.machine_id) {
|
||||||
this.machine = fetchedMachine;
|
this.machine = fetchedMachine;
|
||||||
this.machineService.setSelectedMachine(fetchedMachine);
|
this.machineService.setSelectedMachine(fetchedMachine);
|
||||||
this.loadVendingState(fetchedMachine.machine_id);
|
this.loadVendingState(fetchedMachine.machine_id);
|
||||||
this.errorMessage = null; // Clear any previous error
|
this.errorMessage = null;
|
||||||
} else {
|
} else {
|
||||||
console.warn('Could not find machine with ID:', machineId);
|
console.warn('Could not find machine with ID:', machineId);
|
||||||
this.errorMessage = 'Machine not found. Please select a machine from the list.';
|
this.errorMessage = 'Machine not found. Please select a machine from the list.';
|
||||||
this.loadFallbackMachine();
|
this.loadFallbackMachine();
|
||||||
}
|
}
|
||||||
|
this.isLoading = false;
|
||||||
},
|
},
|
||||||
error => {
|
error: (error) => {
|
||||||
console.error('Error fetching machine:', error);
|
console.error('Error fetching machine:', error);
|
||||||
this.errorMessage = 'Error loading machine details. Please try again.';
|
this.errorMessage = 'Error loading machine details. Please try again.';
|
||||||
|
this.isLoading = false;
|
||||||
this.loadFallbackMachine();
|
this.loadFallbackMachine();
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
} else {
|
} else {
|
||||||
// If no ID in route, try to load from selected machine
|
|
||||||
this.loadFallbackMachine();
|
this.loadFallbackMachine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback to selected machine from service
|
||||||
|
*/
|
||||||
private loadFallbackMachine(): void {
|
private loadFallbackMachine(): void {
|
||||||
const sub = this.machineService.selectedMachine$.subscribe(machine => {
|
const sub = this.machineService.selectedMachine$.subscribe(machine => {
|
||||||
if (machine?.machine_id) {
|
if (machine?.machine_id) {
|
||||||
this.machine = machine;
|
this.machine = machine;
|
||||||
this.loadVendingState(machine.machine_id);
|
this.loadVendingState(machine.machine_id);
|
||||||
// Update the URL with the machine ID to handle refresh
|
|
||||||
this.router.navigate(['/machines', machine.id], {
|
this.router.navigate(['/machines', machine.id], {
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
skipLocationChange: false
|
skipLocationChange: false
|
||||||
});
|
});
|
||||||
this.errorMessage = null; // Clear any previous error
|
this.errorMessage = null;
|
||||||
} else {
|
} else {
|
||||||
console.warn('No machine selected and no ID in route');
|
console.warn('No machine selected and no ID in route');
|
||||||
this.errorMessage = 'No machine selected. Please select a machine from the list.';
|
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);
|
this.subscriptions.push(sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load vending state for machine
|
||||||
|
*/
|
||||||
loadVendingState(machineId: string): void {
|
loadVendingState(machineId: string): void {
|
||||||
this.machineService.getMachineVendingState(machineId).subscribe(
|
this.machineService.getMachineVendingState(machineId).subscribe({
|
||||||
response => {
|
next: (response) => {
|
||||||
if (response?.vendingRows) {
|
if (response?.vendingRows) {
|
||||||
this.updateSlotsFromMachine(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 {
|
updateSlotsFromMachine(machineSlots: any[]): void {
|
||||||
if (!machineSlots?.length) return;
|
if (!machineSlots?.length) return;
|
||||||
|
|
||||||
@ -133,7 +165,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
const rowIndex = this.vendingRows.findIndex(r => r.id === rowData.rowId);
|
const rowIndex = this.vendingRows.findIndex(r => r.id === rowData.rowId);
|
||||||
if (rowIndex !== -1 && rowData.slots) {
|
if (rowIndex !== -1 && rowData.slots) {
|
||||||
rowData.slots.forEach((slotData: any) => {
|
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) {
|
if (slotIndex !== -1) {
|
||||||
const slot = this.vendingRows[rowIndex].slots[slotIndex];
|
const slot = this.vendingRows[rowIndex].slots[slotIndex];
|
||||||
slot.enabled = slotData.enabled;
|
slot.enabled = slotData.enabled;
|
||||||
@ -151,24 +185,71 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all products
|
||||||
|
*/
|
||||||
loadProducts(): void {
|
loadProducts(): void {
|
||||||
this.productService.getProducts().subscribe(
|
this.productService.getProducts().subscribe({
|
||||||
data => {
|
next: (data) => {
|
||||||
this.products = data;
|
this.products = data;
|
||||||
if (this.machine?.machine_id) {
|
if (this.machine?.machine_id) {
|
||||||
this.loadVendingState(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();
|
event.stopPropagation();
|
||||||
slot.enabled = !slot.enabled;
|
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;
|
if (!slot.enabled) return;
|
||||||
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -177,10 +258,14 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
this.showProductSelector = true;
|
this.showProductSelector = true;
|
||||||
this.filteredProducts = [...this.products];
|
this.filteredProducts = [...this.products];
|
||||||
|
|
||||||
|
// Reset selection states
|
||||||
this.filteredProducts.forEach(p => p.selected = false);
|
this.filteredProducts.forEach(p => p.selected = false);
|
||||||
|
|
||||||
|
// Pre-select product if slot has one
|
||||||
if (slot.product) {
|
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) {
|
if (existingProduct) {
|
||||||
existingProduct.selected = true;
|
existingProduct.selected = true;
|
||||||
this.selectedProduct = existingProduct;
|
this.selectedProduct = existingProduct;
|
||||||
@ -195,6 +280,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
this.filterProducts();
|
this.filterProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close product selector modal
|
||||||
|
*/
|
||||||
closeProductSelector(): void {
|
closeProductSelector(): void {
|
||||||
this.showProductSelector = false;
|
this.showProductSelector = false;
|
||||||
this.selectedRow = null;
|
this.selectedRow = null;
|
||||||
@ -203,11 +291,16 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
this.productQuantity = 1;
|
this.productQuantity = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter products based on search term
|
||||||
|
*/
|
||||||
filterProducts(): void {
|
filterProducts(): void {
|
||||||
if (!this.searchTerm.trim()) {
|
if (!this.searchTerm.trim()) {
|
||||||
this.filteredProducts = [...this.products];
|
this.filteredProducts = [...this.products];
|
||||||
if (this.selectedProduct) {
|
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;
|
if (selectedProduct) selectedProduct.selected = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -216,21 +309,27 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
const term = this.searchTerm.toLowerCase().trim();
|
const term = this.searchTerm.toLowerCase().trim();
|
||||||
this.filteredProducts = this.products.filter(product =>
|
this.filteredProducts = this.products.filter(product =>
|
||||||
product.product_name.toLowerCase().includes(term) ||
|
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))
|
(product.category && product.category.toLowerCase().includes(term))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.selectedProduct) {
|
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;
|
if (selectedProduct) selectedProduct.selected = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onProductSelectionChange(product: any): void {
|
/**
|
||||||
|
* Handle product selection change
|
||||||
|
*/
|
||||||
|
onProductSelectionChange(product: Product): void {
|
||||||
this.filteredProducts.forEach(p => p.selected = false);
|
this.filteredProducts.forEach(p => p.selected = false);
|
||||||
product.selected = true;
|
product.selected = true;
|
||||||
this.selectedProduct = product;
|
this.selectedProduct = product;
|
||||||
|
|
||||||
|
// Pre-fill quantity if same product is already in slot
|
||||||
if (this.selectedSlot?.product?.product_id === product.product_id) {
|
if (this.selectedSlot?.product?.product_id === product.product_id) {
|
||||||
this.productQuantity = this.selectedSlot.units || 1;
|
this.productQuantity = this.selectedSlot.units || 1;
|
||||||
} else {
|
} else {
|
||||||
@ -238,19 +337,26 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increase product quantity
|
||||||
|
*/
|
||||||
increaseQuantity(): void {
|
increaseQuantity(): void {
|
||||||
this.productQuantity++;
|
this.productQuantity++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrease product quantity
|
||||||
|
*/
|
||||||
decreaseQuantity(): void {
|
decreaseQuantity(): void {
|
||||||
// ✅ Allow decreasing quantity (minimum 1)
|
|
||||||
if (this.productQuantity > 1) {
|
if (this.productQuantity > 1) {
|
||||||
this.productQuantity--;
|
this.productQuantity--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set quantity from input field
|
||||||
|
*/
|
||||||
setQuantity(event: any): void {
|
setQuantity(event: any): void {
|
||||||
// ✅ Allow setting quantity always
|
|
||||||
const value = parseInt(event.target.value);
|
const value = parseInt(event.target.value);
|
||||||
this.productQuantity = (!isNaN(value) && value > 0) ? value : 1;
|
this.productQuantity = (!isNaN(value) && value > 0) ? value : 1;
|
||||||
if (this.productQuantity < 1) {
|
if (this.productQuantity < 1) {
|
||||||
@ -259,6 +365,9 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm product selection and assign to slot
|
||||||
|
*/
|
||||||
confirmProductSelection(): void {
|
confirmProductSelection(): void {
|
||||||
if (!this.selectedProduct || !this.selectedSlot) return;
|
if (!this.selectedProduct || !this.selectedSlot) return;
|
||||||
|
|
||||||
@ -269,13 +378,19 @@ export class MachineDetailsComponent implements OnInit, OnDestroy {
|
|||||||
this.closeProductSelector();
|
this.closeProductSelector();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeProductFromSlot(slot: any, event: Event): void {
|
/**
|
||||||
|
* Remove product from slot
|
||||||
|
*/
|
||||||
|
removeProductFromSlot(slot: Slot, event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
slot.product = null;
|
slot.product = null;
|
||||||
slot.units = 0;
|
slot.units = 0;
|
||||||
slot.price = 'N/A';
|
slot.price = 'N/A';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save all changes to backend
|
||||||
|
*/
|
||||||
saveChanges(): void {
|
saveChanges(): void {
|
||||||
if (!this.machine.machine_id) {
|
if (!this.machine.machine_id) {
|
||||||
console.error('Cannot save: No machine ID available');
|
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)
|
this.machineService.updateMachineVendingState(this.machine.machine_id, vendingState)
|
||||||
.subscribe(
|
.subscribe({
|
||||||
response => {
|
next: (response) => {
|
||||||
console.log('Vending state updated:', response);
|
console.log('Vending state updated:', response);
|
||||||
alert('Machine configuration saved successfully!');
|
alert('Machine configuration saved successfully!');
|
||||||
this.errorMessage = null;
|
this.errorMessage = null;
|
||||||
|
this.isLoading = false;
|
||||||
},
|
},
|
||||||
error => {
|
error: (error) => {
|
||||||
console.error('Error updating vending state:', error);
|
console.error('Error updating vending state:', error);
|
||||||
this.errorMessage = 'Failed to save machine configuration. Please try again.';
|
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']);
|
this.router.navigate(['/machines/machines-list']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -21,6 +21,33 @@ export interface Machine {
|
|||||||
last_maintenance_date?: string;
|
last_maintenance_date?: string;
|
||||||
next_maintenance_date?: string;
|
next_maintenance_date?: string;
|
||||||
firmware_version?: 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 {
|
export interface VendingStateResponse {
|
||||||
@ -34,6 +61,7 @@ export interface VendingStateResponse {
|
|||||||
export class MachineService {
|
export class MachineService {
|
||||||
private apiUrl = `${environment.apiUrl}/machines`;
|
private apiUrl = `${environment.apiUrl}/machines`;
|
||||||
private usersUrl = `${environment.apiUrl}/users`;
|
private usersUrl = `${environment.apiUrl}/users`;
|
||||||
|
private backendBaseUrl = environment.apiUrl;
|
||||||
|
|
||||||
// 🔹 Loading state
|
// 🔹 Loading state
|
||||||
private loadingSource = new BehaviorSubject<boolean>(false);
|
private loadingSource = new BehaviorSubject<boolean>(false);
|
||||||
@ -45,9 +73,10 @@ export class MachineService {
|
|||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
// =====================
|
// =============================================
|
||||||
// 🔹 Shared state methods
|
// SHARED STATE METHODS
|
||||||
// =====================
|
// =============================================
|
||||||
|
|
||||||
setSelectedMachine(machine: Machine): void {
|
setSelectedMachine(machine: Machine): void {
|
||||||
this.selectedMachineSource.next(machine);
|
this.selectedMachineSource.next(machine);
|
||||||
}
|
}
|
||||||
@ -56,9 +85,10 @@ export class MachineService {
|
|||||||
this.selectedMachineSource.next(null);
|
this.selectedMachineSource.next(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =============================================
|
||||||
// 🔹 CRUD methods
|
// BASIC MACHINE CRUD OPERATIONS
|
||||||
// =====================
|
// =============================================
|
||||||
|
|
||||||
getMachines(): Observable<Machine[]> {
|
getMachines(): Observable<Machine[]> {
|
||||||
this.loadingSource.next(true);
|
this.loadingSource.next(true);
|
||||||
return this.http.get<Machine[]>(this.apiUrl).pipe(
|
return this.http.get<Machine[]>(this.apiUrl).pipe(
|
||||||
@ -68,16 +98,25 @@ export class MachineService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getUsers(): Observable<any[]> {
|
getMachineById(id: number): Observable<Machine> {
|
||||||
return this.http.get<any[]>(this.usersUrl).pipe(
|
return this.http.get<Machine>(`${this.apiUrl}/${id}`).pipe(
|
||||||
tap((users) => console.log('Fetched users:', users)),
|
tap((m) => console.log('Fetched machine by id:', m)),
|
||||||
catchError(this.handleError<any[]>('getUsers', []))
|
catchError(this.handleError<Machine>('getMachineById', {} as Machine))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addMachine(machine: Machine): Observable<Machine> {
|
addMachine(machine: Machine): Observable<Machine> {
|
||||||
this.loadingSource.next(true);
|
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)),
|
tap((newMachine) => console.log('Added new machine:', newMachine)),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
console.error('Error adding machine:', error);
|
console.error('Error adding machine:', error);
|
||||||
@ -89,34 +128,88 @@ export class MachineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateMachine(id: number, machine: Machine): Observable<Machine> {
|
updateMachine(id: number, machine: Machine): Observable<Machine> {
|
||||||
this.loadingSource.next(true);
|
this.loadingSource.next(true);
|
||||||
return this.http.put<Machine>(`${this.apiUrl}/${id}`, machine).pipe(
|
|
||||||
tap((updated) => console.log('Updated machine:', updated)),
|
// Don't send created_by or assigned_refillers in updates
|
||||||
catchError(this.handleError<Machine>('updateMachine', {} as Machine)),
|
const payload = { ...machine };
|
||||||
tap(() => this.loadingSource.next(false))
|
delete payload.created_by;
|
||||||
);
|
delete payload.created_by_username;
|
||||||
}
|
delete payload.assigned_refillers;
|
||||||
|
delete payload.showPassword;
|
||||||
deleteMachine(id: number): Observable<any> {
|
|
||||||
this.loadingSource.next(true);
|
return this.http.put<Machine>(`${this.apiUrl}/${id}`, payload).pipe(
|
||||||
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
|
tap((updated) => console.log('Updated machine:', updated)),
|
||||||
tap(() => console.log('Deleted machine with ID:', id)),
|
catchError(this.handleError<Machine>('updateMachine', {} as Machine)),
|
||||||
catchError(this.handleError<any>('deleteMachine')),
|
tap(() => this.loadingSource.next(false))
|
||||||
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))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
updateMachineStatus(
|
||||||
id: number,
|
id: number,
|
||||||
status: { operation_status?: string; connection_status?: string }
|
status: { operation_status?: string; connection_status?: string }
|
||||||
@ -127,9 +220,13 @@ deleteMachine(id: number): Observable<any> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// VENDING STATE OPERATIONS
|
||||||
|
// =============================================
|
||||||
|
|
||||||
getMachineVendingState(machineId: string): Observable<VendingStateResponse> {
|
getMachineVendingState(machineId: string): Observable<VendingStateResponse> {
|
||||||
return this.http
|
return this.http
|
||||||
.get<VendingStateResponse>(`${this.apiUrl}/${machineId}`)
|
.get<VendingStateResponse>(`${this.apiUrl}/${machineId}/vending-state`)
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((state) => console.log('Fetched vending state:', state)),
|
tap((state) => console.log('Fetched vending state:', state)),
|
||||||
catchError(
|
catchError(
|
||||||
@ -149,6 +246,10 @@ deleteMachine(id: number): Observable<any> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// MAINTENANCE OPERATIONS
|
||||||
|
// =============================================
|
||||||
|
|
||||||
getMachineMaintenanceHistory(machineId: string): Observable<any[]> {
|
getMachineMaintenanceHistory(machineId: string): Observable<any[]> {
|
||||||
return this.http
|
return this.http
|
||||||
.get<any[]>(`${this.apiUrl}/${machineId}/maintenance-history`)
|
.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) {
|
private handleError<T>(operation = 'operation', result?: T) {
|
||||||
return (error: HttpErrorResponse): Observable<T> => {
|
return (error: HttpErrorResponse): Observable<T> => {
|
||||||
console.error(`${operation} failed:`, error.message);
|
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);
|
return of(result as T);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,6 +47,7 @@
|
|||||||
<div>Operation</div>
|
<div>Operation</div>
|
||||||
<div>Connection</div>
|
<div>Connection</div>
|
||||||
<div>Password</div>
|
<div>Password</div>
|
||||||
|
<div>Created By</div>
|
||||||
<div>Actions</div>
|
<div>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,6 +73,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<!-- Edit button - ONLY for admins -->
|
<!-- Edit button - ONLY for admins -->
|
||||||
@ -145,6 +152,7 @@
|
|||||||
</mat-option>
|
</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<!-- Branch -->
|
<!-- Branch -->
|
||||||
<mat-form-field class="w-full sm:w-1/2 pl-2">
|
<mat-form-field class="w-full sm:w-1/2 pl-2">
|
||||||
<mat-label>Branch Name</mat-label>
|
<mat-label>Branch Name</mat-label>
|
||||||
@ -176,6 +184,13 @@
|
|||||||
<input matInput formControlName="password" type="password" />
|
<input matInput formControlName="password" type="password" />
|
||||||
</mat-form-field>
|
</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>
|
||||||
</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 {
|
.grid.min-w-max {
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure parent containers allow proper responsive behavior */
|
|
||||||
.flex.flex-auto.overflow-auto {
|
.flex.flex-auto.overflow-auto {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@ -18,6 +20,177 @@
|
|||||||
min-width: 0;
|
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
|
INVENTORY GRID - BASE LAYOUT
|
||||||
============================================ */
|
============================================ */
|
||||||
@ -26,146 +199,115 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 16px 24px;
|
||||||
gap: 12px;
|
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) {
|
@media (min-width: 1400px) {
|
||||||
.inventory-grid {
|
.inventory-grid {
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
60px /* 1. ID */
|
60px 140px 180px 140px 180px 180px 150px 150px 150px 150px 200px;
|
||||||
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 */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Large Laptop (1024px - 1399px) */
|
/* Large Laptop */
|
||||||
@media (min-width: 1024px) and (max-width: 1399px) {
|
@media (min-width: 1024px) and (max-width: 1399px) {
|
||||||
.inventory-grid {
|
.inventory-grid {
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
50px /* 1. ID */
|
50px 120px 150px 110px 140px 140px 130px 130px 120px 130px 170px;
|
||||||
120px /* 2. Machine ID */
|
padding: 12px 20px;
|
||||||
150px /* 3. Model */
|
|
||||||
110px /* 4. Type */
|
|
||||||
140px /* 5. Client */
|
|
||||||
140px /* 6. Branch */
|
|
||||||
130px /* 7. Operation */
|
|
||||||
130px /* 8. Connection */
|
|
||||||
120px /* 9. Password */
|
|
||||||
170px; /* 10. Actions */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet Landscape (768px - 1023px) - Hide Type, Connection, Password */
|
/* Tablet Landscape */
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
.inventory-grid {
|
.inventory-grid {
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
50px /* 1. ID */
|
50px 120px 150px minmax(120px, 1fr) 130px 120px 160px;
|
||||||
120px /* 2. Machine ID */
|
padding: 12px 16px;
|
||||||
150px /* 3. Model */
|
|
||||||
minmax(120px, 1fr) /* 5. Client */
|
|
||||||
130px /* 6. Branch */
|
|
||||||
120px /* 7. Operation */
|
|
||||||
160px; /* 10. Actions */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide Type (column 4) */
|
|
||||||
.inventory-grid > div:nth-child(4),
|
.inventory-grid > div:nth-child(4),
|
||||||
.inventory-grid > div:nth-child(8),
|
.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;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet Portrait (576px - 767px) - Show ID, Machine ID, Model, Actions */
|
/* Tablet Portrait */
|
||||||
@media (min-width: 576px) and (max-width: 767px) {
|
@media (min-width: 576px) and (max-width: 767px) {
|
||||||
.inventory-grid {
|
.inventory-grid {
|
||||||
grid-template-columns:
|
grid-template-columns: 45px minmax(100px, 1fr) 140px 180px;
|
||||||
45px /* 1. ID */
|
padding: 12px 16px;
|
||||||
minmax(100px, 1fr) /* 2. Machine ID */
|
|
||||||
140px /* 3. Model */
|
|
||||||
180px; /* 10. Actions */
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide columns 4-9 */
|
|
||||||
.inventory-grid > div:nth-child(4),
|
.inventory-grid > div:nth-child(4),
|
||||||
.inventory-grid > div:nth-child(5),
|
.inventory-grid > div:nth-child(5),
|
||||||
.inventory-grid > div:nth-child(6),
|
.inventory-grid > div:nth-child(6),
|
||||||
.inventory-grid > div:nth-child(7),
|
.inventory-grid > div:nth-child(7),
|
||||||
.inventory-grid > div:nth-child(8),
|
.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;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjust action buttons */
|
|
||||||
.inventory-grid .flex.gap-2 {
|
.inventory-grid .flex.gap-2 {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-grid button {
|
.inventory-grid button {
|
||||||
padding: 4px 8px;
|
padding: 6px 12px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
min-height: 32px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile (< 576px) - Show only ID, Machine ID, Actions */
|
/* Mobile */
|
||||||
@media (max-width: 575px) {
|
@media (max-width: 575px) {
|
||||||
.inventory-grid {
|
.inventory-grid {
|
||||||
grid-template-columns:
|
grid-template-columns: 40px minmax(80px, 1fr) 130px;
|
||||||
40px /* 1. ID */
|
padding: 12px 12px;
|
||||||
minmax(80px, 1fr) /* 2. Machine ID */
|
gap: 12px;
|
||||||
130px; /* 10. Actions */
|
font-size: 13px;
|
||||||
padding: 8px 8px;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide columns 3-9 */
|
|
||||||
.inventory-grid > div:nth-child(3),
|
.inventory-grid > div:nth-child(3),
|
||||||
.inventory-grid > div:nth-child(4),
|
.inventory-grid > div:nth-child(4),
|
||||||
.inventory-grid > div:nth-child(5),
|
.inventory-grid > div:nth-child(5),
|
||||||
.inventory-grid > div:nth-child(6),
|
.inventory-grid > div:nth-child(6),
|
||||||
.inventory-grid > div:nth-child(7),
|
.inventory-grid > div:nth-child(7),
|
||||||
.inventory-grid > div:nth-child(8),
|
.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;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stack action buttons vertically */
|
|
||||||
.inventory-grid .flex.gap-2 {
|
.inventory-grid .flex.gap-2 {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-grid button {
|
.inventory-grid button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
padding: 6px 4px;
|
padding: 8px 6px;
|
||||||
min-height: 36px;
|
min-height: 40px;
|
||||||
min-width: auto !important;
|
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 {
|
.inventory-grid.text-secondary {
|
||||||
@ -173,6 +315,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
font-size: 14px;
|
||||||
border-bottom: 2px solid #e5e7eb;
|
border-bottom: 2px solid #e5e7eb;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -184,13 +327,6 @@
|
|||||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
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
|
DATA ROW STYLING
|
||||||
============================================ */
|
============================================ */
|
||||||
@ -201,36 +337,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inventory-grid:not(.text-secondary):hover {
|
.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 {
|
.dark .inventory-grid:not(.text-secondary):hover {
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
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 {
|
.inventory-grid > div:last-child {
|
||||||
white-space: normal;
|
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 {
|
.relative.flex.flex-0.flex-col.border-b {
|
||||||
padding: 16px;
|
padding: 24px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.relative.flex.flex-0.flex-col.border-b {
|
.relative.flex.flex-0.flex-col.border-b {
|
||||||
padding: 16px 12px;
|
padding: 20px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-4xl.font-extrabold.tracking-tight {
|
.text-4xl.font-extrabold.tracking-tight {
|
||||||
@ -238,7 +400,6 @@
|
|||||||
line-height: 2.25rem;
|
line-height: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search and button container */
|
|
||||||
.mt-6.flex.shrink-0.items-center {
|
.mt-6.flex.shrink-0.items-center {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch !important;
|
align-items: stretch !important;
|
||||||
@ -247,13 +408,11 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search field takes full width */
|
|
||||||
mat-form-field.fuse-mat-dense.fuse-mat-rounded.min-w-64 {
|
mat-form-field.fuse-mat-dense.fuse-mat-rounded.min-w-64 {
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add button takes full width */
|
|
||||||
.mt-6.flex.shrink-0.items-center > button.ml-4 {
|
.mt-6.flex.shrink-0.items-center > button.ml-4 {
|
||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
width: 100%;
|
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 FOOTER BUTTONS
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* 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)
|
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.flex.w-full.items-center.justify-between.border-t {
|
.flex.w-full.items-center.justify-between.border-t {
|
||||||
padding: 16px 24px;
|
padding: 20px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Delete button */
|
|
||||||
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
|
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
|
||||||
margin-left: 0 !important;
|
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 {
|
.flex.w-full.items-center.justify-between.border-t > .flex.items-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create/Update button */
|
|
||||||
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
|
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
|
||||||
min-height: 40px;
|
min-height: 44px;
|
||||||
padding: 0 24px;
|
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) {
|
@media (max-width: 767px) {
|
||||||
.flex.w-full.items-center.justify-between.border-t {
|
.flex.w-full.items-center.justify-between.border-t {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch !important;
|
align-items: stretch !important;
|
||||||
padding: 16px;
|
padding: 20px 24px;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Delete button full width */
|
|
||||||
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
|
.flex.w-full.items-center.justify-between.border-t > button.-ml-4 {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
order: 2;
|
order: 2;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty div placeholder - hide on mobile */
|
|
||||||
.flex.w-full.items-center.justify-between.border-t > div:empty {
|
.flex.w-full.items-center.justify-between.border-t > div:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right side container full width */
|
|
||||||
.flex.w-full.items-center.justify-between.border-t > .flex.items-center {
|
.flex.w-full.items-center.justify-between.border-t > .flex.items-center {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
order: 1;
|
order: 1;
|
||||||
@ -368,29 +489,32 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flash message centered */
|
|
||||||
.flex.w-full.items-center.justify-between.border-t .mr-4.flex.items-center {
|
.flex.w-full.items-center.justify-between.border-t .mr-4.flex.items-center {
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create/Update button full width */
|
|
||||||
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
|
.flex.w-full.items-center.justify-between.border-t button[color="primary"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.mat-mdc-form-field.w-full.sm\:w-1\/2 {
|
||||||
@media (max-width: 575px) {
|
width: 100% !important;
|
||||||
.flex.w-full.items-center.justify-between.border-t {
|
padding: 0 !important;
|
||||||
padding: 12px 8px;
|
margin: 0 0 16px 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex.w-full.items-center.justify-between.border-t button {
|
.mat-mdc-form-field.w-full.sm\:w-1\/2.pr-2,
|
||||||
min-height: 52px;
|
.mat-mdc-form-field.w-full.sm\:w-1\/2.pl-2 {
|
||||||
font-size: 14px;
|
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 {
|
.mr-4.flex.items-center {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mr-4.flex.items-center mat-icon {
|
.mr-4.flex.items-center mat-icon {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 575px) {
|
.mr-4.flex.items-center:has(mat-icon.text-green-500) {
|
||||||
.mr-4.flex.items-center {
|
background-color: #d4edda;
|
||||||
font-size: 13px;
|
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 {
|
mat-paginator {
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
mat-paginator {
|
mat-paginator {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-mdc-paginator-container {
|
.mat-mdc-paginator-container {
|
||||||
padding: 8px !important;
|
padding: 12px !important;
|
||||||
flex-wrap: wrap;
|
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 {
|
::ng-deep .mat-mdc-form-field-infix {
|
||||||
margin: 0 !important;
|
min-height: 52px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
::ng-deep .mat-mdc-option {
|
||||||
/* ============================================
|
padding: 10px 12px;
|
||||||
PASSWORD FIELD WITH TOGGLE
|
min-height: 44px;
|
||||||
============================================ */
|
font-size: 14px;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
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
|
EMPTY STATE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.border-t.p-8.text-center {
|
.border-t.p-8.text-center {
|
||||||
padding: 48px 24px;
|
padding: 60px 32px;
|
||||||
}
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
@media (max-width: 575px) {
|
|
||||||
.border-t.p-8.text-center {
|
|
||||||
padding: 32px 16px;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
DARK MODE SUPPORT
|
DARK MODE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.dark .flex.border-b {
|
.dark .flex.border-b,
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
.dark .border-b,
|
||||||
}
|
|
||||||
|
|
||||||
.dark .border-b {
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .border-t {
|
.dark .border-t {
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
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,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible {
|
select:focus-visible {
|
||||||
outline: 2px solid #2563eb;
|
outline: 2px solid #2563eb;
|
||||||
outline-offset: 2px;
|
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 {
|
p.mt-2.text-sm.text-gray-600 {
|
||||||
.inventory-grid {
|
padding: 8px 16px;
|
||||||
grid-template-columns: auto auto auto auto auto auto auto auto auto auto;
|
background-color: #fef3c7;
|
||||||
font-size: 10px;
|
border-left: 4px solid #f59e0b;
|
||||||
padding: 4px;
|
border-radius: 4px;
|
||||||
}
|
margin-top: 12px;
|
||||||
|
}
|
||||||
.inventory-grid button {
|
|
||||||
display: none;
|
.dark p.mt-2.text-sm.text-gray-600 {
|
||||||
}
|
background-color: rgba(251, 191, 36, 0.1);
|
||||||
|
border-left-color: #fbbf24;
|
||||||
mat-paginator {
|
color: #fcd34d;
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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({
|
@Component({
|
||||||
selector: 'app-brand',
|
selector: 'app-brand',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
],
|
||||||
templateUrl: './brand.component.html',
|
templateUrl: './brand.component.html',
|
||||||
styleUrl: './brand.component.scss'
|
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({
|
@Component({
|
||||||
selector: 'app-categories',
|
selector: 'app-categories',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [
|
||||||
|
CommonModule, ReactiveFormsModule, MatPaginatorModule, MatSortModule,
|
||||||
|
MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule,
|
||||||
|
MatIconModule, MatTooltipModule, MatProgressBarModule, MatSnackBarModule
|
||||||
|
],
|
||||||
templateUrl: './categories.component.html',
|
templateUrl: './categories.component.html',
|
||||||
styleUrl: './categories.component.scss'
|
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">
|
<div class="flex flex-auto flex-col overflow-hidden sm:mb-18 sm:overflow-y-auto">
|
||||||
@if (products && products.length > 0 || showAddForm) {
|
@if (products && products.length > 0 || showAddForm) {
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<!-- Header -->
|
<!-- Header - NOW WITH 9 COLUMNS -->
|
||||||
<div
|
<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"
|
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
|
matSort
|
||||||
@ -64,19 +64,19 @@
|
|||||||
<div [mat-sort-header]="'product_id'">Product ID</div>
|
<div [mat-sort-header]="'product_id'">Product ID</div>
|
||||||
<div [mat-sort-header]="'product_name'">Product Name</div>
|
<div [mat-sort-header]="'product_name'">Product Name</div>
|
||||||
<div class="hidden sm:block" [mat-sort-header]="'price'">Price</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 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 class="hidden sm:block">Details</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Product Form (when toggled) -->
|
<!-- Add Product Form -->
|
||||||
@if (showAddForm) {
|
@if (showAddForm) {
|
||||||
<div class="grid">
|
<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">
|
<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>
|
<div></div>
|
||||||
<!-- Auto-generated ID placeholder -->
|
|
||||||
<div class="text-gray-500 italic">Auto-generated</div>
|
<div class="text-gray-500 italic">Auto-generated</div>
|
||||||
<!-- Product name input -->
|
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field class="w-full fuse-mat-dense">
|
<mat-form-field class="w-full fuse-mat-dense">
|
||||||
<input
|
<input
|
||||||
@ -87,7 +87,6 @@
|
|||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<!-- Price input -->
|
|
||||||
<div class="hidden sm:block">
|
<div class="hidden sm:block">
|
||||||
<mat-form-field class="w-full fuse-mat-dense">
|
<mat-form-field class="w-full fuse-mat-dense">
|
||||||
<span matPrefix>₹</span>
|
<span matPrefix>₹</span>
|
||||||
@ -100,11 +99,15 @@
|
|||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</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">
|
<div class="hidden lg:block text-gray-500">
|
||||||
{{ currentDate | date : 'fullDate' }}
|
{{ currentDate | date : 'fullDate' }}
|
||||||
</div>
|
</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">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
@ -205,6 +208,37 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -263,6 +297,10 @@
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div
|
<div
|
||||||
class="inventory-grid grid items-center gap-4 border-b px-6 py-3 md:px-8"
|
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 -->
|
<!-- Image -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@ -287,21 +325,78 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product ID -->
|
<!-- 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 -->
|
<!-- 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 -->
|
<!-- 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' }}
|
{{ product.price | currency : 'INR' : 'symbol' : '1.2-2' }}
|
||||||
</div>
|
</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">
|
<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' }}
|
{{ product.created_date | date : 'fullDate' }}
|
||||||
</div>
|
</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 -->
|
<!-- Details button -->
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@ -422,6 +517,63 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</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>
|
</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 {
|
.inventory-grid {
|
||||||
display: 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;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
@ -15,18 +15,129 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Grid Adjustments */
|
/* 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) {
|
@media (max-width: 1024px) {
|
||||||
|
/* Hide date columns on medium screens */
|
||||||
.inventory-grid {
|
.inventory-grid {
|
||||||
grid-template-columns: 80px 120px 1fr 140px 80px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
|
/* Minimal columns on small screens */
|
||||||
.inventory-grid {
|
.inventory-grid {
|
||||||
grid-template-columns: 80px 1fr 120px 80px;
|
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 Styles */
|
||||||
.product-image-container {
|
.product-image-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -385,4 +496,54 @@ button[mat-stroked-button] {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: #a8a8a8;
|
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 { Component, OnInit, ViewChild, Input } from '@angular/core';
|
||||||
import { Product, ProductService } from '../product.service';
|
import { Product, ProductService } from '../product.service';
|
||||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormControl } from '@angular/forms';
|
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 { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatRippleModule } from '@angular/material/core';
|
import { MatRippleModule } from '@angular/material/core';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
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({
|
@Component({
|
||||||
selector: 'app-product-list',
|
selector: 'app-product-list',
|
||||||
@ -33,7 +36,9 @@ import { environment } from '@environments/environment';
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
MatRippleModule,
|
MatRippleModule,
|
||||||
MatTooltipModule
|
MatTooltipModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatNativeDateModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ProductListComponent implements OnInit {
|
export class ProductListComponent implements OnInit {
|
||||||
@ -84,14 +89,18 @@ export class ProductListComponent implements OnInit {
|
|||||||
price: ['', [Validators.required, Validators.min(0)]],
|
price: ['', [Validators.required, Validators.min(0)]],
|
||||||
created_date: [{ value: new Date(), disabled: true }],
|
created_date: [{ value: new Date(), disabled: true }],
|
||||||
product_id: [{ value: null, disabled: true }],
|
product_id: [{ value: null, disabled: true }],
|
||||||
category: ['']
|
category: [''],
|
||||||
|
billing_date: [''],
|
||||||
|
expiration_date: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
// New add product form (inline)
|
// New add product form (inline)
|
||||||
this.addProductForm = this.formBuilder.group({
|
this.addProductForm = this.formBuilder.group({
|
||||||
product_name: ['', Validators.required],
|
product_name: ['', Validators.required],
|
||||||
price: [0, [Validators.required, Validators.min(0.01)]],
|
price: [0, [Validators.required, Validators.min(0.01)]],
|
||||||
category: ['']
|
category: [''],
|
||||||
|
billing_date: [''],
|
||||||
|
expiration_date: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
this.searchControl.valueChanges.subscribe(query => {
|
this.searchControl.valueChanges.subscribe(query => {
|
||||||
@ -178,6 +187,58 @@ export class ProductListComponent implements OnInit {
|
|||||||
product.product_image = null;
|
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 {
|
toggleDetails(productId: string): void {
|
||||||
if (this.selectedProduct?.product_id === productId) {
|
if (this.selectedProduct?.product_id === productId) {
|
||||||
this.selectedProduct = null;
|
this.selectedProduct = null;
|
||||||
@ -185,31 +246,33 @@ export class ProductListComponent implements OnInit {
|
|||||||
const prod = this.products.find(p => p.product_id === productId) || null;
|
const prod = this.products.find(p => p.product_id === productId) || null;
|
||||||
this.selectedProduct = prod;
|
this.selectedProduct = prod;
|
||||||
if (prod) {
|
if (prod) {
|
||||||
// Ensure we have the numeric id for updates
|
console.log('Selected product for editing:', prod);
|
||||||
console.log('Selected product for editing:', prod); // Debug log
|
|
||||||
this.productForm.patchValue({
|
this.productForm.patchValue({
|
||||||
product_name: prod.product_name,
|
product_name: prod.product_name,
|
||||||
price: prod.price,
|
price: prod.price,
|
||||||
created_date: prod.created_date,
|
created_date: prod.created_date,
|
||||||
product_id: prod.product_id,
|
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 {
|
toggleAddProduct(): void {
|
||||||
if (this.showAddForm) {
|
if (this.showAddForm) {
|
||||||
this.cancelAddProduct();
|
this.cancelAddProduct();
|
||||||
} else {
|
} else {
|
||||||
// Close any open product details
|
|
||||||
this.selectedProduct = null;
|
this.selectedProduct = null;
|
||||||
this.showAddForm = true;
|
this.showAddForm = true;
|
||||||
this.addProductForm.reset({
|
this.addProductForm.reset({
|
||||||
product_name: '',
|
product_name: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
category: ''
|
category: '',
|
||||||
|
billing_date: '',
|
||||||
|
expiration_date: ''
|
||||||
});
|
});
|
||||||
this.addProductImagePreview = null;
|
this.addProductImagePreview = null;
|
||||||
this.addProductSelectedFile = null;
|
this.addProductSelectedFile = null;
|
||||||
@ -253,6 +316,18 @@ export class ProductListComponent implements OnInit {
|
|||||||
if (category) {
|
if (category) {
|
||||||
formData.append('category', 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) {
|
if (this.addProductSelectedFile) {
|
||||||
formData.append('product_image', this.addProductSelectedFile);
|
formData.append('product_image', this.addProductSelectedFile);
|
||||||
@ -263,11 +338,12 @@ export class ProductListComponent implements OnInit {
|
|||||||
this.isAddingProduct = false;
|
this.isAddingProduct = false;
|
||||||
this.showAddFlashMessage('success');
|
this.showAddFlashMessage('success');
|
||||||
this.loadProducts();
|
this.loadProducts();
|
||||||
// Reset form but keep it open for potential additional entries
|
|
||||||
this.addProductForm.reset({
|
this.addProductForm.reset({
|
||||||
product_name: '',
|
product_name: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
category: ''
|
category: '',
|
||||||
|
billing_date: '',
|
||||||
|
expiration_date: ''
|
||||||
});
|
});
|
||||||
this.addProductImagePreview = null;
|
this.addProductImagePreview = null;
|
||||||
this.addProductSelectedFile = null;
|
this.addProductSelectedFile = null;
|
||||||
@ -306,7 +382,7 @@ export class ProductListComponent implements OnInit {
|
|||||||
updateProduct(): void {
|
updateProduct(): void {
|
||||||
if (!this.selectedProduct?.id) {
|
if (!this.selectedProduct?.id) {
|
||||||
console.error('No product selected for update or missing numeric 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');
|
this.showFlashMessage('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -326,6 +402,18 @@ export class ProductListComponent implements OnInit {
|
|||||||
if (category) {
|
if (category) {
|
||||||
formData.append('category', 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) {
|
if (this.selectedFile) {
|
||||||
formData.append('product_image', this.selectedFile);
|
formData.append('product_image', this.selectedFile);
|
||||||
@ -350,7 +438,6 @@ export class ProductListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteProduct(productId: string): void {
|
deleteProduct(productId: string): void {
|
||||||
// Find the product to get its numeric ID
|
|
||||||
const product = this.products.find(p => p.product_id === productId);
|
const product = this.products.find(p => p.product_id === productId);
|
||||||
if (!product?.id) {
|
if (!product?.id) {
|
||||||
console.error('Product not found or missing numeric ID:', productId);
|
console.error('Product not found or missing numeric ID:', productId);
|
||||||
@ -383,4 +470,16 @@ export class ProductListComponent implements OnInit {
|
|||||||
this.selectedFile = null;
|
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 { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { tap, catchError } from 'rxjs/operators';
|
||||||
|
import { environment } from '@environments/environment';
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id?: number; // Add this - the numeric primary key from backend
|
id?: number;
|
||||||
product_id?: string; // Keep this - the string identifier like "PA020"
|
product_id?: string;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
price: number;
|
price: number;
|
||||||
product_image?: string | null;
|
product_image?: string | null;
|
||||||
created_date: Date | string;
|
created_date: Date | string;
|
||||||
category?: 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({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ProductService {
|
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) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
getProducts(): Observable<Product[]> {
|
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> {
|
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> {
|
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> {
|
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({
|
@Component({
|
||||||
selector: 'app-sub-categories',
|
selector: 'app-sub-categories',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [
|
||||||
|
CommonModule, ReactiveFormsModule, MatPaginatorModule, MatSortModule,
|
||||||
|
MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule,
|
||||||
|
MatIconModule, MatTooltipModule, MatProgressBarModule, MatSnackBarModule
|
||||||
|
],
|
||||||
templateUrl: './sub-categories.component.html',
|
templateUrl: './sub-categories.component.html',
|
||||||
styleUrl: './sub-categories.component.scss'
|
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 { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { AuthService } from 'app/core/auth/auth.service';
|
import { AuthService } from 'app/core/auth/auth.service';
|
||||||
|
import { RoleStateService } from 'app/core/services/role-state.service';
|
||||||
import { environment } from '@environments/environment';
|
import { environment } from '@environments/environment';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
interface Role {
|
interface Role {
|
||||||
id: number;
|
id: number;
|
||||||
@ -28,7 +30,7 @@ interface Permission {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, FormsModule]
|
imports: [CommonModule, ReactiveFormsModule, FormsModule]
|
||||||
})
|
})
|
||||||
export class RoleManagementComponent implements OnInit {
|
export class RoleManagementComponent implements OnInit, OnDestroy {
|
||||||
roles: Role[] = [];
|
roles: Role[] = [];
|
||||||
filteredRoles: Role[] = [];
|
filteredRoles: Role[] = [];
|
||||||
roleForm: FormGroup;
|
roleForm: FormGroup;
|
||||||
@ -45,11 +47,13 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
permissionMap: { [key: string]: Permission } = {};
|
permissionMap: { [key: string]: Permission } = {};
|
||||||
|
|
||||||
private apiUrl = environment.apiUrl;
|
private apiUrl = environment.apiUrl;
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
private http: HttpClient,
|
private http: HttpClient,
|
||||||
private authService: AuthService
|
private authService: AuthService,
|
||||||
|
private roleStateService: RoleStateService
|
||||||
) {
|
) {
|
||||||
this.roleForm = this.fb.group({
|
this.roleForm = this.fb.group({
|
||||||
name: ['', [Validators.required, Validators.minLength(3)]],
|
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||||
@ -63,6 +67,11 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
this.loadRoles();
|
this.loadRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
private getHeaders(): HttpHeaders {
|
private getHeaders(): HttpHeaders {
|
||||||
const token = this.authService.accessToken;
|
const token = this.authService.accessToken;
|
||||||
return new HttpHeaders({
|
return new HttpHeaders({
|
||||||
@ -80,6 +89,7 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
permissions.forEach(p => {
|
permissions.forEach(p => {
|
||||||
this.permissionMap[p.id] = p;
|
this.permissionMap[p.id] = p;
|
||||||
});
|
});
|
||||||
|
console.log('✓ Permissions loaded:', permissions.length);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error('Error loading permissions:', error);
|
console.error('Error loading permissions:', error);
|
||||||
@ -100,6 +110,7 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
this.roles = roles;
|
this.roles = roles;
|
||||||
this.filteredRoles = [...roles];
|
this.filteredRoles = [...roles];
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
console.log('✓ Roles loaded:', roles.length);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error('Error loading roles:', error);
|
console.error('Error loading roles:', error);
|
||||||
@ -124,6 +135,7 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.roleForm.invalid) {
|
if (this.roleForm.invalid) {
|
||||||
|
this.showError('Please fill in all required fields correctly');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,15 +150,30 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
{ headers: this.getHeaders() }
|
{ headers: this.getHeaders() }
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (updatedRole) => {
|
next: (updatedRole) => {
|
||||||
|
// Update role in local arrays
|
||||||
const index = this.roles.findIndex(r => r.id === this.currentRoleId);
|
const index = this.roles.findIndex(r => r.id === this.currentRoleId);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.roles[index] = updatedRole;
|
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.showSuccess('Role updated successfully');
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
this.showAddRoleForm = false;
|
this.showAddRoleForm = false;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|
||||||
|
console.log('✓ Role updated:', updatedRole);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error('Error updating role:', error);
|
console.error('Error updating role:', error);
|
||||||
@ -162,12 +189,23 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
{ headers: this.getHeaders() }
|
{ headers: this.getHeaders() }
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (newRole) => {
|
next: (newRole) => {
|
||||||
|
// Add new role to arrays
|
||||||
this.roles.push(newRole);
|
this.roles.push(newRole);
|
||||||
this.filteredRoles = [...this.roles];
|
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.showSuccess('Role created successfully');
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
this.showAddRoleForm = false;
|
this.showAddRoleForm = false;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|
||||||
|
console.log('✓ Role created:', newRole);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error('Error creating role:', error);
|
console.error('Error creating role:', error);
|
||||||
@ -184,13 +222,18 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
this.roleForm.patchValue({
|
this.roleForm.patchValue({
|
||||||
name: role.name,
|
name: role.name,
|
||||||
description: role.description,
|
description: role.description,
|
||||||
permissions: role.permissions
|
permissions: [...role.permissions]
|
||||||
});
|
});
|
||||||
this.showAddRoleForm = true;
|
this.showAddRoleForm = true;
|
||||||
|
|
||||||
|
console.log('Editing role:', role.name, 'Permissions:', role.permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRole(id: number): void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,10 +241,17 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
this.http.delete(`${this.apiUrl}/roles/${id}`, { headers: this.getHeaders() })
|
this.http.delete(`${this.apiUrl}/roles/${id}`, { headers: this.getHeaders() })
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
// Remove from both arrays
|
||||||
this.roles = this.roles.filter(r => r.id !== id);
|
this.roles = this.roles.filter(r => r.id !== id);
|
||||||
this.filteredRoles = this.filteredRoles.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.showSuccess('Role deleted successfully');
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|
||||||
|
console.log('✓ Role deleted, ID:', id);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error('Error deleting role:', error);
|
console.error('Error deleting role:', error);
|
||||||
@ -254,6 +304,8 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
const newPermissions = currentPermissions.filter(id => !modulePermissionIds.includes(id));
|
const newPermissions = currentPermissions.filter(id => !modulePermissionIds.includes(id));
|
||||||
this.roleForm.get('permissions')?.setValue(newPermissions);
|
this.roleForm.get('permissions')?.setValue(newPermissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.roleForm.get('permissions')?.markAsDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
isModuleFullySelected(module: string): boolean {
|
isModuleFullySelected(module: string): boolean {
|
||||||
@ -262,7 +314,8 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
.map(p => p.id);
|
.map(p => p.id);
|
||||||
|
|
||||||
const currentPermissions = this.roleForm.get('permissions')?.value || [];
|
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[] {
|
getPermissionsByModule(module: string): Permission[] {
|
||||||
@ -286,10 +339,18 @@ export class RoleManagementComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.roleForm.get('permissions')?.setValue(permissions);
|
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 {
|
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];
|
const updatedPermissions = [...role.permissions];
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!updatedPermissions.includes(permissionId)) {
|
if (!updatedPermissions.includes(permissionId)) {
|
||||||
updatedPermissions.push(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.http.put<Role>(
|
||||||
`${this.apiUrl}/roles/${role.id}`,
|
`${this.apiUrl}/roles/${role.id}`,
|
||||||
{ permissions: updatedPermissions },
|
{ permissions: updatedPermissions },
|
||||||
{ headers: this.getHeaders() }
|
{ headers: this.getHeaders() }
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (updatedRole) => {
|
next: (updatedRole) => {
|
||||||
const roleIndex = this.roles.findIndex(r => r.id === role.id);
|
console.log('✓ Permission updated on backend:', updatedRole);
|
||||||
if (roleIndex !== -1) {
|
|
||||||
this.roles[roleIndex] = updatedRole;
|
// Update with backend response
|
||||||
this.filteredRoles = [...this.roles];
|
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) => {
|
error: (error) => {
|
||||||
console.error('Error updating permissions:', error);
|
console.error('Error updating permissions:', error);
|
||||||
this.showError('Failed to update permissions');
|
this.showError('Failed to update permissions');
|
||||||
|
|
||||||
|
// Rollback on error
|
||||||
|
this.loadRoles();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private showSuccess(message: string): void {
|
private showSuccess(message: string): void {
|
||||||
this.successMessage = message;
|
this.successMessage = message;
|
||||||
|
this.errorMessage = null;
|
||||||
setTimeout(() => this.successMessage = null, 3000);
|
setTimeout(() => this.successMessage = null, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showError(message: string): void {
|
private showError(message: string): void {
|
||||||
this.errorMessage = message;
|
this.errorMessage = message;
|
||||||
|
this.successMessage = null;
|
||||||
setTimeout(() => this.errorMessage = null, 5000);
|
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 { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, BehaviorSubject } from 'rxjs';
|
import { Observable, BehaviorSubject } from 'rxjs';
|
||||||
@ -16,9 +16,11 @@ export interface Role {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
permissions: string[]; // Array of permission IDs
|
permissions: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
created_by?: number;
|
||||||
|
created_by_username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@ -33,8 +35,7 @@ export class RoleService {
|
|||||||
public roles$ = this._roles.asObservable();
|
public roles$ = this._roles.asObservable();
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
constructor(private http: HttpClient) {
|
||||||
// Load roles on service initialization
|
console.log('✓ RoleService initialized');
|
||||||
this.loadRoles();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,20 +45,11 @@ export class RoleService {
|
|||||||
return this.http.get<Role[]>(this.apiUrl).pipe(
|
return this.http.get<Role[]>(this.apiUrl).pipe(
|
||||||
tap(roles => {
|
tap(roles => {
|
||||||
this._roles.next(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
|
* Get single role by ID
|
||||||
*/
|
*/
|
||||||
@ -69,27 +61,21 @@ export class RoleService {
|
|||||||
* Create new role
|
* Create new role
|
||||||
*/
|
*/
|
||||||
createRole(role: Partial<Role>): Observable<Role> {
|
createRole(role: Partial<Role>): Observable<Role> {
|
||||||
return this.http.post<Role>(this.apiUrl, role).pipe(
|
return this.http.post<Role>(this.apiUrl, role);
|
||||||
tap(() => this.loadRoles()) // Reload after creation
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update existing role
|
* Update existing role
|
||||||
*/
|
*/
|
||||||
updateRole(id: number, role: Partial<Role>): Observable<Role> {
|
updateRole(id: number, role: Partial<Role>): Observable<Role> {
|
||||||
return this.http.put<Role>(`${this.apiUrl}/${id}`, role).pipe(
|
return this.http.put<Role>(`${this.apiUrl}/${id}`, role);
|
||||||
tap(() => this.loadRoles()) // Reload after update
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete role
|
* Delete role
|
||||||
*/
|
*/
|
||||||
deleteRole(id: number): Observable<any> {
|
deleteRole(id: number): Observable<any> {
|
||||||
return this.http.delete(`${this.apiUrl}/${id}`).pipe(
|
return this.http.delete(`${this.apiUrl}/${id}`);
|
||||||
tap(() => this.loadRoles()) // Reload after deletion
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -100,32 +86,7 @@ export class RoleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter roles based on current user's role
|
* Convert Role array to dropdown format
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
rolesToDropdownFormat(roles: Role[]): { value: string; label: string }[] {
|
rolesToDropdownFormat(roles: Role[]): { value: string; label: string }[] {
|
||||||
return roles.map(role => ({
|
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 {
|
getRoleByName(name: string): Role | undefined {
|
||||||
return this._roles.getValue().find(role => role.name === name);
|
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$ | async; as users) {
|
||||||
@if (users.length > 0 || selectedUser) {
|
@if (users.length > 0 || selectedUser) {
|
||||||
<div class="grid">
|
<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"
|
<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>
|
matSort matSortDisableClear>
|
||||||
<div class="hidden md:block" [mat-sort-header]="'id'">ID</div>
|
<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]="'contact'">Contact</div>
|
||||||
<div class="hidden lg:block" [mat-sort-header]="'roles'">Role</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 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 class="hidden sm:block">Details</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -91,6 +97,22 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 -->
|
<!-- Details button -->
|
||||||
<div class="hidden sm:block">
|
<div class="hidden sm:block">
|
||||||
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button [color]="'primary'"
|
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button [color]="'primary'"
|
||||||
@ -178,6 +200,40 @@
|
|||||||
}
|
}
|
||||||
</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>{{ 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 -->
|
<!-- Details button -->
|
||||||
<div class="hidden sm:block">
|
<div class="hidden sm:block">
|
||||||
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
|
<button class="h-7 min-h-7 min-w-10 px-2 leading-6" mat-stroked-button
|
||||||
@ -344,6 +400,33 @@
|
|||||||
</mat-form-field>
|
</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) {
|
@if (user.id) {
|
||||||
<div class="mt-4 rounded-lg bg-gray-50 p-4">
|
<div class="mt-4 rounded-lg bg-gray-50 p-4">
|
||||||
<h4 class="text-sm font-medium text-gray-900">Account Information</h4>
|
<h4 class="text-sm font-medium text-gray-900">Account Information</h4>
|
||||||
@ -359,6 +442,60 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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 -->
|
<!-- Documents Section -->
|
||||||
<div class="flex w-full flex-col mt-6 pt-6 border-t">
|
<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>
|
<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 {
|
.user-grid {
|
||||||
// Default: ID, Username, Email, Contact, Role, Status, Details (7 columns)
|
// Default: All 10 columns visible on XL screens
|
||||||
grid-template-columns: 60px 2fr 2fr 1.5fr 1fr 1fr 80px;
|
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;
|
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 */
|
/* Material form field customizations */
|
||||||
.fuse-mat-dense {
|
.fuse-mat-dense {
|
||||||
.mat-mdc-form-field-subscript-wrapper {
|
.mat-mdc-form-field-subscript-wrapper {
|
||||||
@ -52,6 +119,12 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-size-3 {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom button styles */
|
/* Custom button styles */
|
||||||
.mat-mdc-button {
|
.mat-mdc-button {
|
||||||
&.mat-primary {
|
&.mat-primary {
|
||||||
@ -271,6 +344,7 @@
|
|||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Additional styles for user.component.scss */
|
/* Additional styles for user.component.scss */
|
||||||
|
|
||||||
/* File upload section styles */
|
/* File upload section styles */
|
||||||
@ -585,6 +659,7 @@
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-type-badge {
|
.doc-type-badge {
|
||||||
&.aadhar { background: #fef2f2; color: #991b1b; }
|
&.aadhar { background: #fef2f2; color: #991b1b; }
|
||||||
&.pan { background: #eff6ff; color: #1e40af; }
|
&.pan { background: #eff6ff; color: #1e40af; }
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// user.component.ts - UPDATED WITH DYNAMIC ROLE FETCHING
|
// user.component.ts - WITH REAL-TIME ROLE UPDATES
|
||||||
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core';
|
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, AfterViewInit } from '@angular/core';
|
||||||
import { FormBuilder, FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
import { FormBuilder, FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||||
import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
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 { debounceTime, distinctUntilChanged, map, startWith, takeUntil } from 'rxjs/operators';
|
||||||
import { User, UserService, UserDocument, DocumentWithMetadata } from '../user/user.service';
|
import { User, UserService, UserDocument, DocumentWithMetadata } from '../user/user.service';
|
||||||
import { AuthService } from 'app/core/auth/auth.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({
|
@Component({
|
||||||
selector: 'app-user',
|
selector: 'app-user',
|
||||||
@ -41,7 +56,7 @@ import { RoleService, Role } from '../role-management/role-management.service';
|
|||||||
MatTooltipModule
|
MatTooltipModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class UserComponent implements OnInit, OnDestroy {
|
export class UserComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||||
@ViewChild(MatSort) sort!: MatSort;
|
@ViewChild(MatSort) sort!: MatSort;
|
||||||
|
|
||||||
@ -58,13 +73,25 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
selectedUser: User | null = null;
|
selectedUser: User | null = null;
|
||||||
flashMessage: 'success' | 'error' | null = null;
|
flashMessage: 'success' | 'error' | null = null;
|
||||||
|
|
||||||
// Role-based properties - UPDATED TO USE DYNAMIC ROLES
|
// Role-based properties
|
||||||
currentUserRole: string | null = null;
|
currentUserRole: string | null = null;
|
||||||
isClient: boolean = false;
|
isClient: boolean = false;
|
||||||
isAdmin: boolean = false;
|
isAdmin: boolean = false;
|
||||||
availableRoles: { value: string; label: string }[] = [];
|
availableRoles: { value: string; label: string }[] = [];
|
||||||
allRoles: Role[] = []; // Store all roles from database
|
allRoles: Role[] = [];
|
||||||
rolesLoading: boolean = false;
|
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
|
// Forms
|
||||||
searchInputControl: FormControl = new FormControl('');
|
searchInputControl: FormControl = new FormControl('');
|
||||||
@ -106,16 +133,18 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
private _formBuilder: FormBuilder,
|
private _formBuilder: FormBuilder,
|
||||||
private _changeDetectorRef: ChangeDetectorRef,
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
private _authService: AuthService,
|
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({
|
this.selectedUserForm = this._formBuilder.group({
|
||||||
username: ['', [Validators.required]],
|
username: ['', [Validators.required]],
|
||||||
email: ['', [Validators.required, Validators.email]],
|
email: ['', [Validators.required, Validators.email]],
|
||||||
contact: ['', [Validators.required, Validators.pattern('^[0-9]{10}$')]],
|
contact: ['', [Validators.required, Validators.pattern('^[0-9]{10}$')]],
|
||||||
roles: ['', [Validators.required]],
|
roles: ['', [Validators.required]],
|
||||||
user_status: ['Active'],
|
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']);
|
this.isAdmin = this._authService.hasAnyRole(['Management', 'SuperAdmin', 'Admin']);
|
||||||
|
|
||||||
console.log('User Management - Current Role:', this.currentUserRole);
|
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();
|
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.users$ = this._users.asObservable();
|
||||||
|
|
||||||
this.searchInputControl.valueChanges
|
this.searchInputControl.valueChanges
|
||||||
@ -143,14 +202,152 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
this.filterUsers();
|
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 {
|
private loadRolesFromDatabase(): void {
|
||||||
this.rolesLoading = true;
|
this.rolesLoading = true;
|
||||||
|
this.rolesLoaded = false;
|
||||||
|
|
||||||
|
console.log('Loading roles from database...');
|
||||||
|
|
||||||
this._roleService.getRoles()
|
this._roleService.getRoles()
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
@ -163,11 +360,15 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
this.setAvailableRoles(roles);
|
this.setAvailableRoles(roles);
|
||||||
|
|
||||||
this.rolesLoading = false;
|
this.rolesLoading = false;
|
||||||
|
this.rolesLoaded = true;
|
||||||
this._changeDetectorRef.markForCheck();
|
this._changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
console.log('Available roles for dropdown:', this.availableRoles);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('✗ Error loading roles:', error);
|
console.error('✗ Error loading roles:', error);
|
||||||
this.rolesLoading = false;
|
this.rolesLoading = false;
|
||||||
|
this.rolesLoaded = true;
|
||||||
|
|
||||||
// Fallback to empty roles if error
|
// Fallback to empty roles if error
|
||||||
this.availableRoles = [];
|
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 {
|
private setAvailableRoles(roles: Role[]): void {
|
||||||
let filteredRoles: Role[] = [];
|
let filteredRoles: Role[] = [];
|
||||||
@ -202,6 +403,52 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
console.log('Available roles for dropdown:', this.availableRoles);
|
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
|
* Refresh roles from database
|
||||||
*/
|
*/
|
||||||
@ -210,29 +457,38 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
this.loadRolesFromDatabase();
|
this.loadRolesFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
/**
|
||||||
this._unsubscribeAll.next(null);
|
* Check if form is ready to submit
|
||||||
this._unsubscribeAll.complete();
|
*/
|
||||||
|
isFormReady(): boolean {
|
||||||
|
return this.selectedUserForm.valid && !this.isLoading && !this.rolesLoading && this.rolesLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
/**
|
||||||
if (this.paginator) {
|
* Debug form validation
|
||||||
this.paginator.page
|
*/
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
debugFormValidation(): void {
|
||||||
.subscribe((page: PageEvent) => {
|
console.log('=== FORM VALIDATION DEBUG ===');
|
||||||
this.pagination.page = page.pageIndex;
|
console.log('Form valid:', this.selectedUserForm.valid);
|
||||||
this.pagination.size = page.pageSize;
|
console.log('Form errors:', this.selectedUserForm.errors);
|
||||||
this.filterUsers();
|
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
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
|
console.log(`✓ ${key}:`, control?.value);
|
||||||
if (this.sort) {
|
}
|
||||||
this.sort.sortChange
|
});
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
console.log('=== END DEBUG ===');
|
||||||
.subscribe((sort: Sort) => {
|
|
||||||
this.sortUsers(sort);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByFn(index: number, item: any): any {
|
trackByFn(index: number, item: any): any {
|
||||||
@ -336,14 +592,30 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
contact: user.contact,
|
contact: user.contact,
|
||||||
roles: user.roles,
|
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.photoPreview = this._userService.getFullFileUrl(user.photo);
|
||||||
this.logoPreview = this._userService.getFullFileUrl(user.company_logo);
|
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')?.clearValidators();
|
||||||
this.selectedUserForm.get('password')?.updateValueAndValidity();
|
this.selectedUserForm.get('password')?.updateValueAndValidity();
|
||||||
|
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,15 +631,32 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
this.photoPreview = null;
|
this.photoPreview = null;
|
||||||
this.logoPreview = null;
|
this.logoPreview = null;
|
||||||
this.viewingDocument = null;
|
this.viewingDocument = null;
|
||||||
|
this.selectedMachineIds = [];
|
||||||
|
this.selectedUserForm.reset();
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
createUser(): void {
|
createUser(): void {
|
||||||
|
console.log('Creating new user...');
|
||||||
|
console.log('Roles loaded:', this.rolesLoaded);
|
||||||
|
console.log('Available roles:', this.availableRoles);
|
||||||
|
|
||||||
if (this.selectedUser) {
|
if (this.selectedUser) {
|
||||||
this.closeDetails();
|
this.closeDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For clients, auto-set role to Refiller
|
// Determine default role
|
||||||
const defaultRole = this.isClient ? 'Refiller' : '';
|
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 = {
|
const newUser: User = {
|
||||||
username: '',
|
username: '',
|
||||||
@ -380,17 +669,27 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.selectedUser = newUser;
|
this.selectedUser = newUser;
|
||||||
|
|
||||||
|
// Reset form and set defaults
|
||||||
this.selectedUserForm.reset();
|
this.selectedUserForm.reset();
|
||||||
this.selectedUserForm.patchValue({
|
this.selectedUserForm.patchValue({
|
||||||
user_status: 'Active',
|
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')?.setValidators([Validators.required, Validators.minLength(6)]);
|
||||||
this.selectedUserForm.get('password')?.updateValueAndValidity();
|
this.selectedUserForm.get('password')?.updateValueAndValidity();
|
||||||
|
|
||||||
|
// Scroll to top
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
|
||||||
this._changeDetectorRef.markForCheck();
|
this._changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
// Debug form state
|
||||||
|
this.debugFormValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
onPhotoSelected(event: any): void {
|
onPhotoSelected(event: any): void {
|
||||||
@ -520,14 +819,30 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedUser(): void {
|
updateSelectedUser(): void {
|
||||||
|
// Debug form before submission
|
||||||
|
this.debugFormValidation();
|
||||||
|
|
||||||
if (!this.selectedUserForm.valid) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const formValue = this.selectedUserForm.value;
|
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) {
|
if (this.selectedUser?.id) {
|
||||||
|
// Update existing user
|
||||||
this._userService.updateUser(
|
this._userService.updateUser(
|
||||||
this.selectedUser.id,
|
this.selectedUser.id,
|
||||||
formValue,
|
formValue,
|
||||||
@ -538,18 +853,40 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
() => {
|
() => {
|
||||||
this.loadUsers();
|
console.log('✓ User updated successfully');
|
||||||
this.showFlashMessage('success');
|
|
||||||
this.closeDetails();
|
// Update machine assignments if Refiller
|
||||||
this.isLoading = false;
|
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) => {
|
(error) => {
|
||||||
console.error('Error updating user:', error);
|
console.error('✗ Error updating user:', error);
|
||||||
this.showFlashMessage('error');
|
this.showFlashMessage('error');
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Create new user
|
||||||
this._userService.addUser(
|
this._userService.addUser(
|
||||||
formValue,
|
formValue,
|
||||||
this.selectedPhoto || undefined,
|
this.selectedPhoto || undefined,
|
||||||
@ -558,14 +895,35 @@ export class UserComponent implements OnInit, OnDestroy {
|
|||||||
)
|
)
|
||||||
.pipe(takeUntil(this._unsubscribeAll))
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
() => {
|
(created) => {
|
||||||
this.loadUsers();
|
console.log('✓ User created successfully');
|
||||||
this.showFlashMessage('success');
|
|
||||||
this.closeDetails();
|
// Assign machines if Refiller
|
||||||
this.isLoading = false;
|
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) => {
|
(error) => {
|
||||||
console.error('Error creating user:', error);
|
console.error('✗ Error creating user:', error);
|
||||||
this.showFlashMessage('error');
|
this.showFlashMessage('error');
|
||||||
this.isLoading = false;
|
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 { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
@ -27,6 +27,21 @@ export interface User {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
machines?: any[];
|
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 {
|
export interface DocumentWithMetadata {
|
||||||
@ -35,6 +50,21 @@ export interface DocumentWithMetadata {
|
|||||||
documentTypeOther?: string;
|
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({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@ -70,6 +100,40 @@ export class UserService {
|
|||||||
return this.http.get<User[]>(this.BaseUrl);
|
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(
|
addUser(
|
||||||
user: Partial<User>,
|
user: Partial<User>,
|
||||||
photoFile?: File,
|
photoFile?: File,
|
||||||
@ -77,6 +141,10 @@ export class UserService {
|
|||||||
documentFiles?: DocumentWithMetadata[]
|
documentFiles?: DocumentWithMetadata[]
|
||||||
): Observable<User> {
|
): Observable<User> {
|
||||||
const formData = this.createFormData(user, photoFile, logoFile, documentFiles);
|
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);
|
return this.http.post<User>(this.BaseUrl, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +157,10 @@ export class UserService {
|
|||||||
): Observable<User> {
|
): Observable<User> {
|
||||||
const url = `${this.BaseUrl}/${id}`;
|
const url = `${this.BaseUrl}/${id}`;
|
||||||
const formData = this.createFormData(user, photoFile, logoFile, documentFiles);
|
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);
|
console.log('PUT Request URL:', url);
|
||||||
return this.http.put<User>(url, formData);
|
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.user_status) formData.append('user_status', user.user_status);
|
||||||
if (user.password) formData.append('password', user.password);
|
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
|
// Append photo file
|
||||||
if (photoFile) {
|
if (photoFile) {
|
||||||
formData.append('photo', photoFile, photoFile.name);
|
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 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 class="flex-auto p-6 sm:p-10 overflow-auto">
|
||||||
<div *ngIf="errorMessage" class="text-red-500 text-center mb-4">
|
<!-- Error Message -->
|
||||||
{{ errorMessage }}
|
<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>
|
||||||
<div class="grid w-full min-w-0 grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
|
||||||
<!-- Machines -->
|
<!-- Top Metrics Row -->
|
||||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<div class="flex items-start justify-between">
|
<!-- Machines Card with Menu -->
|
||||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
<div class="bg-blue-600 text-white rounded-lg p-5 shadow relative">
|
||||||
{{ machineTitle }}
|
<div class="flex items-start justify-between mb-3">
|
||||||
</div>
|
<div class="text-sm font-medium">{{ machineTitle }}</div>
|
||||||
<div class="-mr-3 -mt-2 ml-2">
|
<button mat-icon-button [matMenuTriggerFor]="machineMenu" class="text-white -mt-2 -mr-2">
|
||||||
<button mat-icon-button [matMenuTriggerFor]="summaryMenu">
|
<mat-icon class="text-white text-xl">more_vert</mat-icon>
|
||||||
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_mini:ellipsis-vertical'"></mat-icon>
|
</button>
|
||||||
|
<mat-menu #machineMenu="matMenu">
|
||||||
|
<button mat-menu-item (click)="updateMachine('all')">
|
||||||
|
<mat-icon>list</mat-icon>
|
||||||
|
<span>All Machines</span>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #summaryMenu="matMenu">
|
<button mat-menu-item (click)="updateMachine('active')">
|
||||||
<button mat-menu-item (click)="updateMachine('active')">
|
<mat-icon>check_circle</mat-icon>
|
||||||
Active Machines
|
<span>Active Machines</span>
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="updateMachine('inactive')">
|
<button mat-menu-item (click)="updateMachine('inactive')">
|
||||||
Inactive Machines
|
<mat-icon>cancel</mat-icon>
|
||||||
</button>
|
<span>Inactive Machines</span>
|
||||||
<button mat-menu-item (click)="updateMachine('all')">
|
</button>
|
||||||
Reset Machines
|
</mat-menu>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-5xl font-bold">{{ machineCount }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clients -->
|
<!-- Clients -->
|
||||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
<div class="bg-indigo-600 text-white rounded-lg p-5 shadow">
|
||||||
<div class="flex items-start justify-between">
|
<div class="text-sm font-medium mb-3">Clients</div>
|
||||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
<div class="text-5xl font-bold">{{ clients }}</div>
|
||||||
Clients
|
</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>
|
</div>
|
||||||
<div class="mt-2 flex flex-col items-center">
|
<div class="border-t border-white/30 pt-3 space-y-1">
|
||||||
<div class="text-7xl font-bold leading-none tracking-tight text-red-500 sm:text-8xl">
|
<div class="flex justify-between text-xs">
|
||||||
{{ clients }}
|
<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>
|
</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]">
|
<!-- Other Metrics - Takes 2 columns -->
|
||||||
<div class="flex items-start justify-between">
|
<div class="lg:col-span-2 grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
<!-- Company Users -->
|
||||||
Company Users
|
<div class="bg-teal-600 text-white rounded-lg p-5 shadow">
|
||||||
</div>
|
<div class="text-sm font-medium mb-3">Company Users</div>
|
||||||
|
<div class="text-5xl font-bold">{{ companyUsers }}</div>
|
||||||
</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">
|
<!-- Client Users -->
|
||||||
{{ companyUsers }}
|
<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>
|
</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]">
|
<!-- Machine Stock Status -->
|
||||||
<div class="flex items-start justify-between">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
<div class="flex items-center justify-between mb-4">
|
||||||
Client User
|
<h3 class="text-base font-semibold text-gray-900">Machine Stock Status</h3>
|
||||||
</div>
|
<mat-icon class="text-blue-600 cursor-pointer">filter_list</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-col items-center">
|
<div class="grid grid-cols-5 gap-2">
|
||||||
<div class="text-7xl font-bold leading-none tracking-tight text-red-500 sm:text-8xl">
|
<div *ngFor="let key of getStockStatusKeys()"
|
||||||
{{ clientUsers }}
|
class="text-center p-3 rounded-lg"
|
||||||
</div>
|
[ngClass]="{
|
||||||
</div>
|
'bg-green-100 text-green-800': key === '75 - 100%',
|
||||||
</div>
|
'bg-yellow-100 text-yellow-800': key === '50 - 75%',
|
||||||
<!-- Transactions -->
|
'bg-orange-100 text-orange-800': key === '25 - 50%',
|
||||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full h-[150px]">
|
'bg-red-100 text-red-800': key === '0 - 25%',
|
||||||
<div class="flex items-start justify-between">
|
'bg-pink-100 text-pink-800': key === 'N/A'
|
||||||
<div class="truncate text-lg font-medium leading-6 tracking-tight">
|
}">
|
||||||
Transactions
|
<div class="text-[10px] font-medium mb-1">{{ key }}</div>
|
||||||
</div>
|
<div class="text-2xl font-bold">{{ machineStockStatus[key] }}</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>
|
</div>
|
||||||
</div>
|
</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">
|
<!-- Charts Section -->
|
||||||
<!-- Bar Chart for Key Metrics -->
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full">
|
<!-- Product Sales Chart -->
|
||||||
<div class="text-lg font-medium leading-6 tracking-tight mb-4">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
Key Metrics Overview
|
<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>
|
</div>
|
||||||
<apx-chart
|
<apx-chart
|
||||||
[series]="barChartOptions.series"
|
[series]="productSalesChartOptions.series"
|
||||||
[chart]="barChartOptions.chart"
|
[chart]="productSalesChartOptions.chart"
|
||||||
[xaxis]="barChartOptions.xaxis"
|
[xaxis]="productSalesChartOptions.xaxis"
|
||||||
[colors]="barChartOptions.colors"
|
[yaxis]="productSalesChartOptions.yaxis"
|
||||||
[plotOptions]="barChartOptions.plotOptions"
|
[colors]="productSalesChartOptions.colors"
|
||||||
[dataLabels]="barChartOptions.dataLabels"
|
[plotOptions]="productSalesChartOptions.plotOptions"
|
||||||
[tooltip]="barChartOptions.tooltip"
|
[dataLabels]="productSalesChartOptions.dataLabels"
|
||||||
|
[fill]="productSalesChartOptions.fill"
|
||||||
|
[legend]="productSalesChartOptions.legend"
|
||||||
|
[grid]="productSalesChartOptions.grid"
|
||||||
></apx-chart>
|
></apx-chart>
|
||||||
</div>
|
</div>
|
||||||
<!-- Line Chart for Trends -->
|
|
||||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full">
|
<!-- Top Selling Products -->
|
||||||
<div class="text-lg font-medium leading-6 tracking-tight mb-4">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
Transaction Trends
|
<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>
|
</div>
|
||||||
<apx-chart
|
<div class="overflow-auto max-h-[320px]">
|
||||||
[series]="lineChartOptions.series"
|
<table class="w-full">
|
||||||
[chart]="lineChartOptions.chart"
|
<thead class="bg-teal-600 text-white sticky top-0">
|
||||||
[xaxis]="lineChartOptions.xaxis"
|
<tr>
|
||||||
[yaxis]="lineChartOptions.yaxis"
|
<th class="text-left p-3 text-xs font-semibold">Product</th>
|
||||||
[colors]="lineChartOptions.colors"
|
<th class="text-right p-3 text-xs font-semibold">Quantity</th>
|
||||||
[stroke]="lineChartOptions.stroke"
|
</tr>
|
||||||
[tooltip]="lineChartOptions.tooltip"
|
</thead>
|
||||||
></apx-chart>
|
<tbody>
|
||||||
</div>
|
<tr *ngFor="let product of topSellingProducts; let i = index"
|
||||||
<!-- Doughnut Chart for Sales Distribution -->
|
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||||
<div class="bg-card flex flex-grow flex-col overflow-hidden rounded-2xl p-6 shadow w-full">
|
[ngClass]="{'bg-gray-50': i % 2 === 0}">
|
||||||
<div class="text-lg font-medium leading-6 tracking-tight mb-4">
|
<td class="p-3 text-sm text-gray-700">{{ product.product_name }}</td>
|
||||||
Sales Distribution
|
<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>
|
</div>
|
||||||
<apx-chart
|
|
||||||
[series]="doughnutChartOptions.series"
|
|
||||||
[chart]="doughnutChartOptions.chart"
|
|
||||||
[labels]="doughnutChartOptions.labels"
|
|
||||||
[colors]="doughnutChartOptions.colors"
|
|
||||||
[responsive]="doughnutChartOptions.responsive"
|
|
||||||
></apx-chart>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Pie Chart for Client User Categories -->
|
</div>
|
||||||
<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 Area Chart -->
|
||||||
Client User Categories
|
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
</div>
|
<div class="flex items-center justify-between mb-4">
|
||||||
<apx-chart
|
<h3 class="text-base font-semibold text-gray-900">Sales Trend</h3>
|
||||||
[series]="pieChartOptions.series"
|
<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">
|
||||||
[chart]="pieChartOptions.chart"
|
<mat-icon class="text-lg">schedule</mat-icon>
|
||||||
[labels]="pieChartOptions.labels"
|
<span class="capitalize font-medium">{{ salesTimeRange }}</span>
|
||||||
[colors]="pieChartOptions.colors"
|
<mat-icon class="text-lg">expand_more</mat-icon>
|
||||||
[responsive]="pieChartOptions.responsive"
|
</button>
|
||||||
></apx-chart>
|
<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>
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -23,10 +23,23 @@ interface DashboardMetrics {
|
|||||||
client_users: number;
|
client_users: number;
|
||||||
transactions: number;
|
transactions: number;
|
||||||
sales: string;
|
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;
|
error?: string;
|
||||||
transaction_trends?: { date: string; transactions: number }[];
|
|
||||||
sales_distribution?: { [key: string]: number };
|
|
||||||
client_user_categories?: { [key: string]: number };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -56,112 +69,41 @@ export class ExampleComponent implements OnInit {
|
|||||||
clientUsers: number = 0;
|
clientUsers: number = 0;
|
||||||
transactions: number = 0;
|
transactions: number = 0;
|
||||||
sales: string = '₹0.00';
|
sales: string = '₹0.00';
|
||||||
|
activeMachines: number = 0;
|
||||||
|
inactiveMachines: number = 0;
|
||||||
errorMessage: string = '';
|
errorMessage: string = '';
|
||||||
loading: boolean = false;
|
loading: boolean = false;
|
||||||
barChartOptions: Partial<ApexOptions>;
|
|
||||||
lineChartOptions: Partial<ApexOptions>;
|
// Payment breakdown
|
||||||
doughnutChartOptions: Partial<ApexOptions>;
|
paymentCash: number = 0;
|
||||||
pieChartOptions: Partial<ApexOptions>;
|
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';
|
private readonly baseUrl = environment.apiUrl || 'http://localhost:5000';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
constructor(private http: HttpClient) {
|
||||||
// Initialize bar chart options with mock data
|
this.initializeCharts();
|
||||||
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' }
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@ -169,35 +111,259 @@ export class ExampleComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateMachine(filter: 'active' | 'inactive' | 'all') {
|
updateMachine(filter: 'active' | 'inactive' | 'all') {
|
||||||
|
console.log('Filter clicked:', filter);
|
||||||
this.fetchDashboardMetrics(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) {
|
private fetchDashboardMetrics(filter: string) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.machineTitle = 'Loading...';
|
this.machineTitle = 'Loading...';
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
const url = `${this.baseUrl}/dashboard-metrics?machine_filter=${filter}`;
|
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, {
|
this.http.get<DashboardMetrics>(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
}
|
}
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
console.log('API Response:', response);
|
console.log('✓ Dashboard API Response:', response);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
this.handleError(`Server Error: ${response.error}`);
|
this.handleError(`Server Error: ${response.error}`);
|
||||||
} else {
|
} else {
|
||||||
this.updateDashboardData(response);
|
this.updateDashboardData(response);
|
||||||
|
console.log('✓ Dashboard data updated successfully');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error: HttpErrorResponse) => {
|
error: (error: HttpErrorResponse) => {
|
||||||
console.error('HTTP Error:', error);
|
console.error('✗ Dashboard HTTP Error:', error);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.handleHttpError(error);
|
this.handleHttpError(error);
|
||||||
}
|
}
|
||||||
@ -205,6 +371,7 @@ export class ExampleComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateDashboardData(response: DashboardMetrics) {
|
private updateDashboardData(response: DashboardMetrics) {
|
||||||
|
// Update basic metrics
|
||||||
this.machineTitle = response.machine_title || 'Machines';
|
this.machineTitle = response.machine_title || 'Machines';
|
||||||
this.machineCount = response.machine_count || 0;
|
this.machineCount = response.machine_count || 0;
|
||||||
this.clients = response.clients || 0;
|
this.clients = response.clients || 0;
|
||||||
@ -212,54 +379,73 @@ export class ExampleComponent implements OnInit {
|
|||||||
this.clientUsers = response.client_users || 0;
|
this.clientUsers = response.client_users || 0;
|
||||||
this.transactions = response.transactions || 0;
|
this.transactions = response.transactions || 0;
|
||||||
this.sales = `₹${response.sales || '0.00'}`;
|
this.sales = `₹${response.sales || '0.00'}`;
|
||||||
|
this.activeMachines = response.active_machines || 0;
|
||||||
|
this.inactiveMachines = response.inactive_machines || 0;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
// Update bar chart
|
// Update payment breakdown
|
||||||
this.barChartOptions = {
|
if (response.payment_breakdown) {
|
||||||
...this.barChartOptions,
|
this.paymentCash = response.payment_breakdown.cash || 0;
|
||||||
series: [{
|
this.paymentCashless = response.payment_breakdown.cashless || 0;
|
||||||
name: 'Metrics',
|
this.paymentUPIWalletCard = response.payment_breakdown.upi_wallet_card || 0;
|
||||||
data: [
|
this.paymentUPIWalletPaytm = response.payment_breakdown.upi_wallet_paytm || 0;
|
||||||
this.machineCount,
|
this.refund = response.payment_breakdown.refund || 0;
|
||||||
this.clients,
|
this.refundProcessed = response.payment_breakdown.refund_processed || 0;
|
||||||
this.companyUsers,
|
this.paymentTotal = response.payment_breakdown.total || 0;
|
||||||
this.clientUsers
|
}
|
||||||
]
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update line chart with transaction trends
|
// Update machine operation status
|
||||||
if (response.transaction_trends) {
|
if (response.machine_operation_status) {
|
||||||
this.lineChartOptions = {
|
this.machineOperationStatus = response.machine_operation_status;
|
||||||
...this.lineChartOptions,
|
}
|
||||||
|
|
||||||
|
// 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: [{
|
series: [{
|
||||||
name: 'Transactions',
|
name: 'Sales',
|
||||||
data: response.transaction_trends.map(item => item.transactions)
|
data: response.product_sales_yearly.map(item => item.amount)
|
||||||
}],
|
}],
|
||||||
xaxis: {
|
xaxis: {
|
||||||
...this.lineChartOptions.xaxis,
|
...this.productSalesChartOptions.xaxis,
|
||||||
categories: response.transaction_trends.map(item => item.date)
|
categories: response.product_sales_yearly.map(item => item.year)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update doughnut chart with sales distribution
|
// Update Sales Chart
|
||||||
if (response.sales_distribution) {
|
if (response.sales_yearly && response.sales_yearly.length > 0) {
|
||||||
this.doughnutChartOptions = {
|
this.salesChartOptions = {
|
||||||
...this.doughnutChartOptions,
|
...this.salesChartOptions,
|
||||||
series: Object.values(response.sales_distribution),
|
series: [{
|
||||||
labels: Object.keys(response.sales_distribution)
|
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
|
getStockStatusKeys(): string[] {
|
||||||
if (response.client_user_categories) {
|
return Object.keys(this.machineStockStatus).sort();
|
||||||
this.pieChartOptions = {
|
}
|
||||||
...this.pieChartOptions,
|
|
||||||
series: Object.values(response.client_user_categories),
|
getOperationStatusKeys(): string[] {
|
||||||
labels: Object.keys(response.client_user_categories)
|
return Object.keys(this.machineOperationStatus).sort();
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(message: string) {
|
private handleError(message: string) {
|
||||||
@ -272,18 +458,15 @@ export class ExampleComponent implements OnInit {
|
|||||||
|
|
||||||
if (error.status === 0) {
|
if (error.status === 0) {
|
||||||
errorMessage += 'Cannot connect to server. Please check if the Flask server is running on the correct port.';
|
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) {
|
} else if (error.status === 404) {
|
||||||
errorMessage += 'API endpoint not found. Please check the server routing.';
|
errorMessage += 'API endpoint not found. Please check the server routing.';
|
||||||
} else if (error.status >= 500) {
|
} else if (error.status >= 500) {
|
||||||
errorMessage += 'Internal server error. Please check the server logs.';
|
errorMessage += 'Internal server error. Please check the server logs.';
|
||||||
} else {
|
} else {
|
||||||
errorMessage += `Status: ${error.status} - ${error.statusText}. `;
|
errorMessage += `Status: ${error.status} - ${error.statusText}. `;
|
||||||
|
errorMessage += `Details: ${error.error?.error || error.message || 'Unknown error'}`;
|
||||||
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'}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handleError(errorMessage);
|
this.handleError(errorMessage);
|
||||||
@ -297,39 +480,18 @@ export class ExampleComponent implements OnInit {
|
|||||||
this.clientUsers = 0;
|
this.clientUsers = 0;
|
||||||
this.transactions = 0;
|
this.transactions = 0;
|
||||||
this.sales = '₹0.00';
|
this.sales = '₹0.00';
|
||||||
|
this.activeMachines = 0;
|
||||||
// Reset chart data
|
this.inactiveMachines = 0;
|
||||||
this.barChartOptions = {
|
this.paymentCash = 0;
|
||||||
...this.barChartOptions,
|
this.paymentCashless = 0;
|
||||||
series: [{
|
this.paymentUPIWalletCard = 0;
|
||||||
name: 'Metrics',
|
this.paymentUPIWalletPaytm = 0;
|
||||||
data: [0, 0, 0, 0]
|
this.refund = 0;
|
||||||
}]
|
this.refundProcessed = 0;
|
||||||
};
|
this.paymentTotal = 0;
|
||||||
|
this.machineOperationStatus = {};
|
||||||
this.lineChartOptions = {
|
this.machineStockStatus = {};
|
||||||
...this.lineChartOptions,
|
this.topSellingProducts = [];
|
||||||
series: [{
|
|
||||||
name: 'Transactions',
|
|
||||||
data: []
|
|
||||||
}],
|
|
||||||
xaxis: {
|
|
||||||
...this.lineChartOptions.xaxis,
|
|
||||||
categories: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.doughnutChartOptions = {
|
|
||||||
...this.doughnutChartOptions,
|
|
||||||
series: [],
|
|
||||||
labels: []
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pieChartOptions = {
|
|
||||||
...this.pieChartOptions,
|
|
||||||
series: [],
|
|
||||||
labels: []
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
retryFetch() {
|
retryFetch() {
|
||||||
|
|||||||
@ -22,22 +22,21 @@
|
|||||||
<link rel="icon" type="image/png" href="favicon-16x16.png" />
|
<link rel="icon" type="image/png" href="favicon-16x16.png" />
|
||||||
<link rel="icon" type="image/png" href="favicon-32x32.png" />
|
<link rel="icon" type="image/png" href="favicon-32x32.png" />
|
||||||
|
|
||||||
<!-- Remove these lines if files don't exist:
|
<!-- Fonts and Styles -->
|
||||||
<link href="fonts/inter/inter.css" rel="stylesheet" />
|
<link href="../public/fonts/inter/inter.css" rel="stylesheet" />
|
||||||
<link href="styles/splash-screen.css" rel="stylesheet" />
|
<link href="../public/styles/splash-screen.css" rel="stylesheet" />
|
||||||
-->
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="mat-typography">
|
<body class="mat-typography">
|
||||||
<!-- Splash screen -->
|
<!-- Splash screen -->
|
||||||
<fuse-splash-screen>
|
<!-- <fuse-splash-screen>
|
||||||
<img src="images/logo/logo.svg" alt="Fuse logo" />
|
<img src="images/logo/logo.svg" alt="Fuse logo" />
|
||||||
<div class="spinner">
|
<div class="spinner">
|
||||||
<div class="bounce1"></div>
|
<div class="bounce1"></div>
|
||||||
<div class="bounce2"></div>
|
<div class="bounce2"></div>
|
||||||
<div class="bounce3"></div>
|
<div class="bounce3"></div>
|
||||||
</div>
|
</div>
|
||||||
</fuse-splash-screen>
|
</fuse-splash-screen> -->
|
||||||
|
|
||||||
<!-- App root -->
|
<!-- App root -->
|
||||||
<app-root></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
|
// src/environments/environment.ts
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
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'
|
payuUrl: 'https://test.payu.in/_payment'
|
||||||
};
|
};
|
||||||